A database query takes 50ms. The same query through Redis takes 0.1ms. Caching removes load from your database and makes your application faster.

Why Cache?

  • Reduce database load — fewer queries hit the database
  • Lower latency — in-memory reads are 100-500x faster than disk reads
  • Absorb traffic spikes — cache handles burst traffic without scaling the database

Caching adds complexity. Only cache what you need to.

Cache-Aside (Lazy Loading)

The most common pattern. Read from cache first; fall back to database on miss.

import Redis from "ioredis";
import { Pool } from "pg";

const redis = new Redis();
const pool = new Pool({ connectionString: process.env.DATABASE_URL });

async function getProduct(id: number) {
  const cacheKey = `product:${id}`;

  // 1. Check cache
  const cached = await redis.get(cacheKey);
  if (cached) {
    return JSON.parse(cached);  // cache hit
  }

  // 2. Cache miss — query database
  const { rows } = await pool.query("SELECT * FROM products WHERE id = $1", [id]);
  if (rows.length === 0) return null;

  const product = rows[0];

  // 3. Store in cache with 5-minute TTL
  await redis.setex(cacheKey, 300, JSON.stringify(product));

  return product;
}

Advantage: Only caches data that is actually requested. Disadvantage: First request is always slow (cold cache miss).

Write-Through

Update cache and database together on every write. Cache is always warm.

async function updateProduct(id: number, data: Partial<Product>) {
  // 1. Update database first
  const { rows } = await pool.query(
    "UPDATE products SET name = $1, price = $2 WHERE id = $3 RETURNING *",
    [data.name, data.price, id]
  );
  const product = rows[0];

  // 2. Update cache immediately
  await redis.setex(`product:${id}`, 300, JSON.stringify(product));

  return product;
}

Advantage: Cache is always up to date. Disadvantage: Every write hits both cache and database. Cached data that is never read wastes memory.

Cache Invalidation

When data changes, you must remove or update the cached version:

async function deleteProduct(id: number) {
  // Delete from database
  await pool.query("DELETE FROM products WHERE id = $1", [id]);

  // Invalidate cache
  await redis.del(`product:${id}`);

  // Also invalidate list caches that include this product
  await redis.del("products:all");
  await redis.del("products:category:electronics");
}

Tagging-based invalidation — track which keys belong to a group:

async function cacheProductWithTags(product: Product) {
  const key = `product:${product.id}`;
  const tagKey = `tag:category:${product.category_id}`;

  // Cache the product
  await redis.setex(key, 3600, JSON.stringify(product));

  // Add key to a tag set
  await redis.sadd(tagKey, key);
  await redis.expire(tagKey, 3600);
}

async function invalidateCategory(categoryId: number) {
  const tagKey = `tag:category:${categoryId}`;

  // Get all keys tagged with this category
  const keys = await redis.smembers(tagKey);

  // Delete all of them
  if (keys.length > 0) {
    await redis.del(...keys, tagKey);
  }
}

Session Store

Redis is ideal for storing user sessions:

import { randomBytes } from "crypto";

async function createSession(userId: number, userData: object) {
  const sessionId = randomBytes(32).toString("hex");
  const key = `session:${sessionId}`;

  await redis.setex(key, 86400, JSON.stringify({ userId, ...userData }));

  return sessionId;
}

async function getSession(sessionId: string) {
  const data = await redis.get(`session:${sessionId}`);
  return data ? JSON.parse(data) : null;
}

async function deleteSession(sessionId: string) {
  await redis.del(`session:${sessionId}`);
}

Rate Limiting

Use a sorted set or INCR to limit requests per time window:

async function isRateLimited(userId: number, limit = 100, windowSeconds = 60): Promise<boolean> {
  const key = `rate:${userId}:${Math.floor(Date.now() / 1000 / windowSeconds)}`;

  const count = await redis.incr(key);
  if (count === 1) {
    await redis.expire(key, windowSeconds);
  }

  return count > limit;
}

// Usage in an API handler
async function handleRequest(userId: number) {
  if (await isRateLimited(userId)) {
    throw new Error("Rate limit exceeded");
  }
  // process request
}

Preventing Cache Stampede

When a popular cached item expires, hundreds of requests all hit the database at once. This is a cache stampede.

Prevention using a lock:

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

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

  // Try to acquire lock (NX = set only if Not eXists)
  const lockAcquired = await redis.set(lockKey, "1", "EX", 10, "NX");

  if (!lockAcquired) {
    // Another process is fetching — wait and retry
    await new Promise((r) => setTimeout(r, 100));
    return getProductSafe(id);
  }

  try {
    // Fetch from database
    const { rows } = await pool.query("SELECT * FROM products WHERE id = $1", [id]);
    const product = rows[0] ?? null;

    if (product) {
      await redis.setex(cacheKey, 300, JSON.stringify(product));
    }
    return product;
  } finally {
    await redis.del(lockKey);
  }
}

Python Example

import redis
import json
import psycopg
from functools import wraps

r = redis.Redis(host="localhost", decode_responses=True)
conn = psycopg.connect("postgresql://localhost/mydb")

def cached(ttl: int = 300):
    """Decorator for cache-aside pattern."""
    def decorator(fn):
        @wraps(fn)
        def wrapper(*args, **kwargs):
            # Build cache key from function name and args
            key = f"cache:{fn.__name__}:{args}:{kwargs}"
            cached = r.get(key)
            if cached:
                return json.loads(cached)

            result = fn(*args, **kwargs)
            if result is not None:
                r.setex(key, ttl, json.dumps(result, default=str))
            return result
        return wrapper
    return decorator

@cached(ttl=300)
def get_product(product_id: int):
    with conn.cursor() as cur:
        cur.execute("SELECT * FROM products WHERE id = %s", (product_id,))
        row = cur.fetchone()
        if row:
            return dict(zip([d[0] for d in cur.description], row))
    return None

What’s Next?

You know caching patterns. Next: Redis Pub/Sub and Streams for real-time messaging and event-driven architectures.

Next: Database Tutorial #15: Redis Pub/Sub and Streams