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