Skip to content

Caching Strategies — Redis, CDN, Application Caching, Cache Invalidation

DodaTech Updated 2026-06-22 14 min read

In this tutorial, you'll learn about Caching Strategies. We cover key concepts, practical examples, and best practices to help you understand and apply this topic effectively.

Caching stores frequently accessed data in a fast storage layer so subsequent requests can be served without recomputing or fetching from slower data sources, reducing latency and backend load by orders of magnitude.

What You'll Learn

By the end of this tutorial, you will implement Redis Caching for database query results and session storage, configure CDN Caching for static assets, apply application-level Caching patterns, handle cache invalidation with TTL and write-through strategies, and prevent cache stampede in high-traffic systems.

Why It Matters

A single database query taking 50ms becomes a bottleneck at 1000 requests per second — the database handles 50 seconds of work per second. Caching reduces this to microseconds. Doda Browser caches search results in Redis for instant autocomplete, uses CDN Caching for static assets, and writes malware signature updates with write-through Caching.

Real-World Use

A news website receives 1 million visitors per hour. Without Caching, every Visitor triggers a database query for the homepage articles. With Redis Caching, the first Visitor triggers the query, and the next 999,999 get the cached result in under 5ms. The article is cached until a new article is published, triggering invalidation.

Caching Layer Architecture

flowchart LR
    C[Client Browser] --> CDN[CDN Cache]
    CDN --> LB[Load Balancer]
    LB --> APP[Application Server]
    APP --> RC[Redis Cache]
    APP --> DB[(Database)]
    RC --> DB
    style CDN fill:#f90,color:#fff
    style RC fill:#22c55e,color:#fff

Redis Caching for Database Queries

# Redis_cache.py
# Redis Caching layer for database queries

import Redis
import JSON
import time
import hashlib

# Requires Redis running locally: Docker run -d -p 6379:6379 Redis:7

class CacheLayer:
    """Redis Caching layer with automatic cache-aside pattern."""

    def __init__(self, Redis_URL='Redis://localhost:6379/0', default_ttl=300):
        self.Redis = Redis.from_URL(Redis_URL)
        self.default_ttl = default_ttl
        self.cache_hits = 0
        self.cache_misses = 0

    def _make_key(self, prefix, query, params=None):
        """Generate a deterministic cache key from query and params."""
        key_data = f"{query}:{JSON.dumps(params or {}, sort_keys=True)}"
        return f"{prefix}:{hashlib.md5(key_data.encode()).hexdigest()}"

    def get_or_compute(self, prefix, query, compute_func, params=None, ttl=None):
        """
        Cache-aside pattern:
        1. Check cache
        2. If miss, compute and store
        3. Return result
        """
        key = self._make_key(prefix, query, params)
        ttl = ttl or self.default_ttl

        # Try cache first
        cached = self.Redis.get(key)
        if cached is not None:
            self.cache_hits += 1
            print(f"[Cache] HIT for {key}")
            return JSON.loads(cached)

        # Cache miss — compute the value
        self.cache_misses += 1
        start = time.time()
        print(f"[Cache] MISS for {key} — computing...")
        result = compute_func(params)
        duration = time.time() - start
        print(f"[Cache] Computed in {duration:.2f}s")

        # Store in cache
        self.Redis.setex(key, ttl, JSON.dumps(result))
        print(f"[Cache] Stored with TTL {ttl}s")

        return result

    def invalidate(self, prefix, query, params=None):
        """Remove a specific cached item."""
        key = self._make_key(prefix, query, params)
        self.Redis.delete(key)
        print(f"[Cache] Invalidated: {key}")

    def invalidate_pattern(self, pattern):
        """Invalidate all keys matching a pattern (use with caution in production)."""
        Cursor = 0
        deleted = 0
        while True:
            Cursor, keys = self.Redis.scan(Cursor, match=pattern, count=100)
            if keys:
                self.Redis.delete(*keys)
                deleted += len(keys)
            if Cursor == 0:
                break
        print(f"[Cache] Invalidated {deleted} keys matching '{pattern}'")

    def stats(self):
        """Return cache hit/miss statistics."""
        total = self.cache_hits + self.cache_misses
        hit_rate = (self.cache_hits / total * 100) if total > 0 else 0
        return {
            'hits': self.cache_hits,
            'misses': self.cache_misses,
            'total': total,
            'hit_rate_percent': round(hit_rate, 1),
        }

# ── Usage ──
cache = CacheLayer()

# Simulate database query function
def get_user_from_db(params):
    """Simulate slow database query."""
    user_id = params['user_id']
    time.sleep(0.5)  # Simulate 500ms query time
    return {
        'id': user_id,
        'name': f'User {user_id}',
        'email': f'user{user_id}@example.com',
        'role': 'admin' if user_id == 1 else 'user',
    }

# First call — cache miss (500ms)
user1 = cache.get_or_compute('user', 'get_user', get_user_from_db, {'user_id': 1})
print(f"User: {user1['name']}\n")

# Second call — cache hit (< 1ms)
user1_cached = cache.get_or_compute('user', 'get_user', get_user_from_db, {'user_id': 1})
print(f"User (cached): {user1_cached['name']}\n")

# Different user — cache miss
user2 = cache.get_or_compute('user', 'get_user', get_user_from_db, {'user_id': 2})
print(f"User: {user2['name']}\n")

# Stats
stats = cache.stats()
print(f"Cache stats: {stats['hits']} hits, {stats['misses']} misses, {stats['hit_rate_percent']}% hit rate")

Expected output:

[Cache] MISS for user:a1b2c3d4... — computing...
[Cache] Computed in 0.50s
[Cache] Stored with TTL 300s
User: User 1

[Cache] HIT for user:a1b2c3d4...
User (cached): User 1

[Cache] MISS for user:e5f6g7h8... — computing...
[Cache] Computed in 0.50s
[Cache] Stored with TTL 300s
User: User 2

Cache stats: 1 hits, 2 misses, 33.3% hit rate

The first call is slow (cache miss, computed). The second call is instant (cache hit). The cache-aside pattern is the most common Caching Strategy and is simple to implement correctly.

CDN Caching Configuration

// CDN-Caching.js
// Express with CDN-friendly Caching headers

const Express = require('Express');
const crypto = require('crypto');

const app = Express();

// Simulated content database
const articles = {
  1: { id: 1, title: 'Caching Strategies', body: '...', updatedAt: '2026-06-20T10:00:00Z' },
  2: { id: 2, title: 'Redis Deep Dive', body: '...', updatedAt: '2026-06-21T08:30:00Z' },
};

// ── Static assets: cache forever with fingerprint ──
app.use('/static', Express.static('public', {
  immutable: true,        // File never changes (fingerprinted name)
  maxAge: '365d',         // Cache for 1 year
  setHeaders: (res, path) => {
    res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
    console.log(`[Static] Served ${path} with immutable cache`);
  },
}));

// ── Dynamic content: ETag-based Caching ──
app.get('/API/articles/:id', (req, res) => {
  const article = articles[parseInt(req.params.id)];
  if (!article) return res.status(404).JSON({ error: 'Not found' });

  // Generate ETag from last modified timestamp
  const etag = crypto.createHash('md5').update(article.updatedAt).digest('hex');

  // Check if client has current version
  if (req.headers['if-none-match'] === `"${etag}"`) {
    console.log(`[CDN] 304 Not Modified for article ${article.id}`);
    return res.status(304).end();
  }

  console.log(`[CDN] Serving article ${article.id} with ETag: ${etag}`);
  res.set({
    'ETag': `"${etag}"`,
    'Cache-Control': 'public, max-age=60, must-revalidate',
    'Last-Modified': article.updatedAt,
  });
  res.JSON(article);
});

// ── API list with shorter cache ──
app.get('/API/articles', (req, res) => {
  const list = Object.values(articles).map(a => ({ id: a.id, title: a.title }));
  res.set('Cache-Control', 'public, max-age=30');
  console.log(`[CDN] Serving article list (${list.length} items), cached 30s`);
  res.JSON(list);
});

app.listen(3000, () => console.log('CDN-optimized server on :3000'));

Expected behavior: Static assets are cached for 1 year with immutable (fingerprinted filenames ensure cache busting on change). Articles use ETag-based conditional Caching — returning 304 Not Modified when the content hasn't changed. The article list is cached for 30 seconds at the CDN level. CDNs like Cloudflare and Fastly respect these headers.

Cache Invalidation Strategies

# cache_invalidation.py
# Write-through and write-behind cache invalidation patterns

import redis
import json
import time

class WriteThroughCache:
    """
    Write-through cache: write to cache AND database simultaneously.
    Ensures cache and database are always consistent.
    """

    def __init__(self, redis_url='redis://localhost:6379/0'):
        self.redis = redis.from_url(redis_url)

    def _article_key(self, article_id):
        return f"article:{article_id}"

    def _list_key(self):
        return "articles:list"

    def write_article(self, article_id, title, body):
        """Write through: update cache and database atomically."""
        article = {
            'id': article_id,
            'title': title,
            'body': body,
            'updated_at': time.time(),
        }

        # Write to database (simulated)
        self._save_to_db(article)

        # Update cache
        self.redis.setex(
            self._article_key(article_id),
            3600,
            json.dumps(article)
        )
        print(f"[Write-Through] Cached article {article_id}")

        # Invalidate list cache (it's now stale)
        self.redis.delete(self._list_key())
        print(f"[Write-Through] Invalidated article list cache")

    def read_article(self, article_id):
        """Read with cache-aside."""
        cached = self.redis.get(self._article_key(article_id))
        if cached:
            print(f"[Read] Cache HIT for article {article_id}")
            return json.loads(cached)

        print(f"[Read] Cache MISS for article {article_id}")
        article = self._load_from_db(article_id)
        if article:
            self.redis.setex(
                self._article_key(article_id),
                3600,
                json.dumps(article)
            )
        return article

    def delete_article(self, article_id):
        """Delete from cache AND database."""
        self._delete_from_db(article_id)
        self.redis.delete(self._article_key(article_id))
        self.redis.delete(self._list_key())
        print(f"[Write-Through] Deleted article {article_id} from cache and DB")

    def _save_to_db(self, article):
        print(f"[DB] Saved article {article['id']}")
        time.sleep(0.05)

    def _load_from_db(self, article_id):
        print(f"[DB] Loaded article {article_id}")
        time.sleep(0.05)
        return {'id': article_id, 'title': 'Test', 'body': '...', 'updated_at': time.time()}

    def _delete_from_db(self, article_id):
        print(f"[DB] Deleted article {article_id}")
        time.sleep(0.02)

# ── Usage ──
cache = WriteThroughCache()

# Write through: updates both cache and DB
cache.write_article(1, 'Caching Guide', 'Caching is important...')

# Read: cache hit (written above)
article = cache.read_article(1)
print(f"Read: {article['title']}")

# Delete from both
cache.delete_article(1)

# Read: cache miss (deleted)
article = cache.read_article(1)
print(f"Read after delete: {article['title']}")

Expected output:

[DB] Saved article 1
[Write-Through] Cached article 1
[Write-Through] Invalidated article list cache
[Read] Cache HIT for article 1
Read: Caching Guide
[DB] Deleted article 1
[Write-Through] Deleted article 1 from cache and DB
[Read] Cache MISS for article 1
[DB] Loaded article 1
Read after delete: Test

Write-through Caching ensures consistency by updating both cache and database in the same operation. The article list cache is invalidated on any write, ensuring subsequent list requests get fresh data.

Cache Stampede Prevention

// cache-stampede.js
// Prevent cache stampede with early recomputation and Mutex locks

const Redis = require('Redis');

class StampedePreventionCache {
  constructor(redisClient) {
    this.Redis = redisClient;
  }

  async getOrCompute(key, computeFn, ttlSeconds = 300) {
    // Try cache first
    const cached = await this.Redis.get(key);
    if (cached) {
      const parsed = JSON.parse(cached);

      // Early recomputation: if TTL is within 10% of expiry, recompute in background
      const ttl = await this.Redis.ttl(key);
      const threshold = Math.ceil(ttlSeconds * 0.1);

      if (ttl > 0 && ttl < threshold) {
        console.log(`[Stampede] Key ${key} near expiry (TTL=${ttl}s), recomputing in background`);
        this.recomputeInBackground(key, computeFn, ttlSeconds);
      }

      return parsed;
    }

    // Cache miss: acquire a distributed lock to prevent stampede
    const lockKey = `lock:${key}`;
    const lockAcquired = await this.Redis.setnx(lockKey, '1');

    if (lockAcquired) {
      // This instance won the lock — compute the value
      await this.Redis.expire(lockKey, 10);  // Lock expires in 10s (safety)
      try {
        console.log(`[Stampede] Cache MISS for ${key}, computing...`);
        const value = await computeFn();
        await this.Redis.setex(key, ttlSeconds, JSON.stringify(value));
        return value;
      } finally {
        await this.Redis.del(lockKey);
      }
    } else {
      // Another instance is computing — wait briefly and retry
      console.log(`[Stampede] Waiting for another instance to compute ${key}`);
      await new Promise(resolve => setTimeout(resolve, 100));

      // Retry cache read
      const retryCached = await this.Redis.get(key);
      if (retryCached) {
        return JSON.parse(retryCached);
      }

      // Fallback: compute anyway (rare case where computing instance failed)
      console.log(`[Stampede] Fallback: computing ${key} after wait timeout`);
      const value = await computeFn();
      await this.Redis.setex(key, ttlSeconds, JSON.stringify(value));
      return value;
    }
  }

  async recomputeInBackground(key, computeFn, ttlSeconds) {
    try {
      const value = await computeFn();
      await this.Redis.setex(key, ttlSeconds, JSON.stringify(value));
      console.log(`[Stampede] Background recompute complete for ${key}`);
    } catch (err) {
      console.error(`[Stampede] Background recompute failed for ${key}:`, err.message);
    }
  }
}

// Usage
async function demo() {
  const client = Redis.createClient({ URL: 'Redis://localhost:6379' });
  await client.connect();

  const cache = new StampedePreventionCache(client);

  let computeCount = 0;
  const expensiveCompute = async () => {
    computeCount++;
    console.log(`[Compute] Running expensive computation (#${computeCount})`);
    await new Promise(R => setTimeout(R, 500));
    return { data: 'expensive result', computedAt: Date.now(), count: computeCount };
  };

  // Simulate 5 concurrent requests
  const results = await Promise.all(
    Array(5).fill(null).map((_, i) =>
      cache.getOrCompute('stampede_test', expensiveCompute, 60)
    )
  );

  console.log(`\nTotal computes: ${computeCount} (expected: 1 with stampede protection)`);
  console.log(`Results match: ${results.every(R => R.count === 1)}`);

  await client.quit();
}

demo().catch(console.error);

Expected behavior: With stampede prevention, only 1 compute runs for 5 concurrent requests instead of 5. Early recomputation refreshes the cache before it fully expires. The distributed lock ensures only one Process computes the value even across multiple servers.

Common Errors

1. Caching Without Invalidation Strategy

Cached data becomes stale when the underlying data changes. Without an invalidation Strategy (TTL, write-through, events), users see outdated information. Always define how and when cached data is invalidated before implementing Caching.

2. Cache Stampede on High-Traffic Keys

When a popular cache key expires, hundreds of concurrent requests all trigger cache misses and simultaneously recompute the value, overwhelming the database. Use early recomputation (refresh before expiry) or Mutex locks to prevent stampedes.

3. Over-Caching Dynamic Content

Caching user-specific data (dashboard, settings) across all users wastes memory and provides no benefit. Each user has unique data, so cache hits are rare. Cache shared data (articles, product listings) and use session storage for user-specific data.

4. Ignoring Cache Serialization Overhead

Storing large JSON objects (> 100KB) in Redis increases network transfer time and memory usage. Compress cached values with gzip or use a Serialization format like MessagePack for large payloads.

5. Not Setting Memory Limits on Redis

Redis stores all data in memory. Without maxmemory and an eviction policy (allkeys-lru, volatile-ttl), Redis uses all available RAM and crashes when memory is exhausted. Always configure maxmemory and choose an appropriate eviction policy.

6. Caching Sensitive Data

Storing PII, passwords, API keys, or payment data in a cache layer (especially a shared cache like Redis) creates a data exposure risk. Never cache sensitive data, or encrypt it before Caching with a short TTL and restricted access.

Practice Questions

1. What is the cache-aside pattern and when should you use it?

The application checks the cache first. On a miss, it loads data from the database, stores it in the cache, and returns it. On a hit, it returns the cached data directly. This is the most common pattern and works well for read-heavy workloads.

2. How does write-through Caching differ from write-behind Caching?

Write-through writes to both cache and database synchronously — consistent but higher write latency. Write-behind writes to cache immediately and asynchronously updates the database — fast writes but risk of data loss if the cache fails before the database write.

3. What is a cache stampede and how do you prevent it?

A cache stampede occurs when many requests simultaneously miss the cache and all try to recompute the value, overwhelming the backend. Prevent it with Mutex locks (only one recomputes), early recomputation (refresh before expiry), or stale-while-revalidate (serve stale data while refreshing in background).

4. Why should CDN cache duration differ for different content types?

Static assets (images, CSS, JS) can be cached for months with fingerprint-based URLs. API responses should be cached for seconds or minutes. User-specific content should not be cached at the CDN at all. Match cache duration to content change frequency.

Challenge

Design a multi-layer Caching system for a product catalog: (1) CDN caches product images and CSS for 1 year (fingerprinted URLs), (2) CDN caches product listing pages for 5 minutes with Cache-Control headers, (3) Redis caches individual product details for 1 hour using cache-aside, (4) Redis caches search results for 15 minutes with invalidation on product update, (5) write-through cache updates product details in Redis when an admin edits a product in the admin panel, (6) cache stampede prevention on the most popular product keys using early recomputation.

Mini Project: Redis Cache Dashboard

# cache_dashboard.py
# Monitor and manage Redis cache from the Command line

import Redis
import JSON
import time
from collections import defaultdict

class CacheDashboard:
    """CLI dashboard for monitoring Redis cache."""

    def __init__(self, Redis_URL='Redis://localhost:6379/0'):
        self.Redis = Redis.from_URL(Redis_URL)

    def show_stats(self):
        """Display cache statistics."""
        info = self.Redis.info()
        print("Redis Cache Dashboard")
        print("=" * 50)
        print(f"Uptime: {info.get('uptime_in_days', 0)} days")
        print(f"Used memory: {info.get('used_memory_human', 'N/A')}")
        print(f"Total keys: {self.Redis.dbsize()}")
        print(f"Connected clients: {info.get('connected_clients', 0)}")
        print(f"Keyspace hits: {info.get('keyspace_hits', 0)}")
        print(f"Keyspace misses: {info.get('keyspace_misses', 0)}")

        hits = info.get('keyspace_hits', 0)
        misses = info.get('keyspace_misses', 0)
        total = hits + misses
        if total > 0:
            print(f"Hit rate: {hits / total * 100:.1f}%")

    def scan_keys(self, pattern='*'):
        """Scan and group keys by prefix."""
        Cursor = 0
        groups = defaultdict(list)

        while True:
            Cursor, keys = self.Redis.scan(Cursor, match=pattern, count=1000)
            for key in keys:
                prefix = key.split(':')[0] if ':' in key else 'other'
                ttl = self.Redis.ttl(key)
                groups[prefix].append({'key': key, 'ttl': ttl})

            if Cursor == 0:
                break

        print(f"\nKey Groups ({sum(len(v) for v in groups.values())} total):")
        print("-" * 50)
        for prefix, keys in sorted(groups.items()):
            ttl_status = f"avg TTL: {sum(k['ttl'] for k in keys) / len(keys):.0f}s" if keys else ""
            print(f"  {prefix}: {len(keys)} keys {ttl_status}")

    def delete_pattern(self, pattern):
        """Delete all keys matching a pattern."""
        Cursor = 0
        deleted = 0
        while True:
            Cursor, keys = self.Redis.scan(Cursor, match=pattern, count=100)
            if keys:
                count = self.Redis.delete(*keys)
                deleted += count
            if Cursor == 0:
                break
        print(f"Deleted {deleted} keys matching '{pattern}'")

    def simulate_load(self, key_prefix, count=100):
        """Simulate cache load for testing."""
        print(f"Simulating {count} cache operations on {key_prefix}:*...")
        for i in range(count):
            key = f"{key_prefix}:test:{i}"
            # Alternate between hit and miss simulation
            if i % 3 == 0:
                self.Redis.setex(key, 60, JSON.dumps({'id': i, 'data': f'value_{i}'}))
            else:
                self.Redis.get(key)  # Miss for non-existent keys

        keys_created = self.Redis.dbsize()
        print(f"Done. Total keys now: {keys_created}")

if __name__ == '__main__':
    dashboard = CacheDashboard()
    dashboard.show_stats()
    dashboard.scan_keys()

Expected output:

Redis Cache Dashboard
==================================================
Uptime: 5 days
Used memory: 12.45M
Total keys: 1523
Connected clients: 2
Keyspace hits: 45201
Keyspace misses: 1234
Hit rate: 97.3%

Key Groups (1523 total):
--------------------------------------------------
  article: 450 keys avg TTL: 234s
  session: 800 keys avg TTL: 1723s
  user: 273 keys avg TTL: 120s

Congratulations on completing this Caching strategies tutorial! Next, explore API Gateway patterns for gateway-level Caching, then learn about backend security best practices for securing cached data.

  • Practice daily — Add Redis Caching to a slow database query and measure the latency improvement
  • Build a project — Build a multi-layer Caching system with Redis, CDN headers, and application-level cache-aside
  • Explore related topics — Check out Varnish cache, Cloudflare Workers for edge Caching, and Redis Stack for RedisJSON and RediSearch

Built by the developers of Doda Browser, DodaZIP, and Durga Antivirus Pro.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro