← All articles
PERFORMANCE Caching Strategies and Tools: Redis, Memcached, CDN,... 2026-02-09 · 6 min read · caching · redis · memcached

Caching Strategies and Tools: Redis, Memcached, CDN, and Application-Level Patterns

Performance 2026-02-09 · 6 min read caching redis memcached cdn performance

Caching Strategies and Tools: Redis, Memcached, CDN, and Application-Level Patterns

Caching is the single most effective tool for improving application performance. A well-placed cache can turn a 200ms database query into a 1ms lookup. But caching also introduces complexity: stale data, cache stampedes, invalidation bugs, and cold-start problems. This guide covers the full caching stack, from browser to CDN to application to data store, with practical patterns that actually work in production.

The Caching Stack

Most production applications use multiple caching layers working together:

Browser Cache (client)
    ↓ miss
CDN / Edge Cache (Cloudflare, Fastly)
    ↓ miss
Reverse Proxy Cache (Nginx, Varnish)
    ↓ miss
Application Cache (in-memory, Redis)
    ↓ miss
Database Query Cache / Materialized Views
    ↓ miss
Origin Database

Each layer has different tradeoffs. The closer to the user, the faster the response but the harder to invalidate.

Browser Caching

Browser caching is free performance. The Cache-Control header controls everything.

# Cache static assets aggressively (1 year)
Cache-Control: public, max-age=31536000, immutable

# Cache API responses briefly (60 seconds)
Cache-Control: private, max-age=60

# Don't cache at all (authentication endpoints, user-specific data)
Cache-Control: no-store

# Revalidate every time (good for HTML pages)
Cache-Control: no-cache

The most common setup for modern apps:

ETag and Conditional Requests

ETags let the browser ask "has this changed?" without downloading the full response:

// Express middleware for ETag-based caching
import { createHash } from "crypto";

app.get("/api/products/:id", async (req, res) => {
  const product = await db.products.findById(req.params.id);
  const etag = createHash("md5")
    .update(JSON.stringify(product))
    .digest("hex");

  res.set("ETag", `"${etag}"`);
  res.set("Cache-Control", "private, max-age=0, must-revalidate");

  if (req.headers["if-none-match"] === `"${etag}"`) {
    return res.status(304).end();
  }

  res.json(product);
});

CDN Caching

A CDN caches your content at edge locations worldwide. For static sites and assets, this is transformative. For APIs, it requires careful configuration.

Cloudflare Cache Rules

# Cache API responses at the edge for 60 seconds
# Cloudflare dashboard: Caching > Cache Rules
Match: hostname eq "api.example.com" and URI path starts with "/v1/public/"
Then: Cache eligible, Edge TTL: 60s, Browser TTL: 30s

# Bypass cache for authenticated requests
Match: hostname eq "api.example.com" and any(http.request.headers["authorization"][*] ne "")
Then: Bypass cache

Surrogate Keys (Cache Tags)

The best CDN caching pattern uses cache tags for surgical invalidation:

// Tag responses with the resources they contain
app.get("/api/products", async (req, res) => {
  const products = await db.products.list();
  res.set("Cache-Tag", products.map(p => `product-${p.id}`).join(","));
  res.set("CDN-Cache-Control", "max-age=3600");
  res.json(products);
});

// When a product updates, purge just that tag
async function onProductUpdate(productId: string) {
  await fetch("https://api.fastly.com/service/{id}/purge/product-" + productId, {
    method: "POST",
    headers: { "Fastly-Key": process.env.FASTLY_API_KEY },
  });
}

This lets you cache aggressively (1-hour TTL) while still invalidating within seconds when data changes.

Application-Level Caching with Redis

Redis is the standard for application-level caching. It's fast (sub-millisecond reads), supports rich data structures, and has built-in expiration.

Local Dev Setup

services:
  redis:
    image: redis:7-alpine
    ports:
      - "6379:6379"
    command: redis-server --maxmemory 256mb --maxmemory-policy allkeys-lru

The allkeys-lru eviction policy means Redis automatically evicts the least recently used keys when memory is full. This is usually what you want for a cache.

Cache-Aside Pattern

The most common caching pattern. The application checks the cache first, falls back to the database on miss, and populates the cache for next time.

import Redis from "ioredis";

const redis = new Redis();
const CACHE_TTL = 300; // 5 minutes

async function getProduct(id: string) {
  // 1. Check cache
  const cached = await redis.get(`product:${id}`);
  if (cached) {
    return JSON.parse(cached);
  }

  // 2. Cache miss — fetch from database
  const product = await db.products.findById(id);
  if (!product) return null;

  // 3. Populate cache
  await redis.set(`product:${id}`, JSON.stringify(product), "EX", CACHE_TTL);

  return product;
}

// Invalidate on write
async function updateProduct(id: string, data: Partial<Product>) {
  await db.products.update(id, data);
  await redis.del(`product:${id}`);
}

Cache Stampede Prevention

When a popular cache key expires, many concurrent requests can hit the database simultaneously. This is a cache stampede. Prevent it with a lock:

async function getProductSafe(id: string) {
  const cacheKey = `product:${id}`;
  const lockKey = `lock:${cacheKey}`;

  const cached = await redis.get(cacheKey);
  if (cached) return JSON.parse(cached);

  // Try to acquire a lock (expires in 5 seconds)
  const acquired = await redis.set(lockKey, "1", "EX", 5, "NX");

  if (acquired) {
    // We got the lock — fetch and cache
    const product = await db.products.findById(id);
    await redis.set(cacheKey, JSON.stringify(product), "EX", CACHE_TTL);
    await redis.del(lockKey);
    return product;
  }

  // Another request is fetching — wait briefly and retry
  await new Promise((r) => setTimeout(r, 100));
  return getProductSafe(id);
}

Write-Through Pattern

For data that's read much more often than it's written, you can update the cache at write time instead of invalidating:

async function updateProduct(id: string, data: Partial<Product>) {
  const product = await db.products.update(id, data);
  // Update cache immediately with the fresh data
  await redis.set(`product:${id}`, JSON.stringify(product), "EX", CACHE_TTL);
  return product;
}

This avoids the cache-miss penalty after every write but introduces a risk of cache-database inconsistency if the cache write fails.

Redis vs Memcached

Feature Redis Memcached
Data structures Strings, hashes, lists, sets, sorted sets, streams Strings only
Persistence Optional (RDB, AOF) None
Eviction policies 8 policies (LRU, LFU, random, TTL-based) LRU only
Max value size 512 MB 1 MB (default)
Threading Single-threaded (multi-threaded I/O in Redis 7) Multi-threaded
Clustering Redis Cluster Client-side sharding
Pub/Sub Yes No
Lua scripting Yes No

Pick Redis unless you have a specific reason not to. It does everything Memcached does and much more. Memcached's only real advantage is slightly better multi-threaded performance for simple get/set workloads with very high concurrency, and even that edge has narrowed with Redis 7's multi-threaded I/O.

Pick Memcached if you're caching large numbers of simple key-value pairs and need to squeeze out every microsecond, or if you're already running it and it's working.

Application-Level Patterns

Memoization (In-Process Cache)

For data that changes rarely and is expensive to compute, an in-process cache avoids the network round-trip to Redis entirely.

import { LRUCache } from "lru-cache";

const configCache = new LRUCache<string, any>({
  max: 500,           // max entries
  ttl: 1000 * 60 * 5, // 5 minutes
});

async function getConfig(key: string) {
  const cached = configCache.get(key);
  if (cached !== undefined) return cached;

  const value = await db.config.get(key);
  configCache.set(key, value);
  return value;
}

Warning: In-process caches don't share state between server instances. If you have 10 servers, each has its own cache, which means 10x memory usage and potential inconsistency. Use in-process caching for truly static data (feature flags, config) and Redis for everything else.

Stale-While-Revalidate

Serve stale data immediately while refreshing in the background. This gives you the consistency of short TTLs with the latency of long ones.

async function getProductSWR(id: string) {
  const cacheKey = `product:${id}`;
  const cached = await redis.get(cacheKey);

  if (cached) {
    const { data, fetchedAt } = JSON.parse(cached);
    const age = Date.now() - fetchedAt;

    if (age < 60_000) {
      // Fresh (under 1 minute) — return as-is
      return data;
    }

    if (age < 300_000) {
      // Stale but usable (1-5 minutes) — return stale, refresh in background
      refreshInBackground(id, cacheKey);
      return data;
    }
  }

  // Cache miss or too stale — fetch synchronously
  return fetchAndCache(id, cacheKey);
}

async function refreshInBackground(id: string, cacheKey: string) {
  // Fire and forget — don't await this
  fetchAndCache(id, cacheKey).catch(console.error);
}

async function fetchAndCache(id: string, cacheKey: string) {
  const data = await db.products.findById(id);
  await redis.set(cacheKey, JSON.stringify({ data, fetchedAt: Date.now() }), "EX", 600);
  return data;
}

Cache Invalidation Strategies

Cache invalidation is famously one of the two hard problems in computer science. Here are the practical approaches:

  1. TTL-based: Set an expiration and accept some staleness. Simplest and most reliable.
  2. Event-driven: Invalidate on write using application events or database triggers.
  3. Tag-based: Associate cache entries with tags and purge by tag (best for CDN caching).
  4. Version-based: Include a version number in the cache key. Increment the version to invalidate all entries.

The right approach depends on your consistency requirements. Most applications can tolerate 30-60 seconds of staleness, which means TTL-based caching with a short TTL is enough. For financial data or inventory counts, use event-driven invalidation.

Monitoring Your Cache

A cache you can't observe is a cache you can't trust. Track these metrics:

# Redis cache stats
redis-cli INFO stats | grep -E "keyspace_hits|keyspace_misses"
redis-cli INFO memory | grep used_memory_human

Start with the simplest caching layer that solves your problem. Browser caching and CDN caching are almost free. Application-level caching with Redis is the next step. Only add more complexity (write-through, stampede prevention, stale-while-revalidate) when you have evidence that you need it.