
Caching Strategies
- 97 installs
- 178 repo stars
- Updated July 14, 2026
- erichowens/some_claude_skills
Implement caching strategies to reduce API costs and improve response latency.
About
Caching Strategies teaches caching patterns for APIs and agents. Reduce API calls, lower costs, and improve performance using effective cache design.
- Cache key design patterns.
- Cost optimization via caching.
Caching Strategies by the numbers
- 97 all-time installs (skills.sh)
- Ranked #3,004 of 4,347 Backend & APIs skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/erichowens/some_claude_skills --skill caching-strategiesAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 97 |
|---|---|
| repo stars | ★ 178 |
| Last updated | July 14, 2026 |
| Repository | erichowens/some_claude_skills ↗ |
What it does
Implement caching strategies to reduce API costs and improve response latency.
Files
Caching Strategies
Caching is the most commonly misapplied performance technique. The failure mode is not "cache too little" — it is "cache without an invalidation strategy and then discover the problem in production six months later when users complain about stale data that you cannot explain."
When to Use
✅ Use for:
- Choosing which caching pattern fits a use case (cache-aside, write-through, write-behind)
- Designing TTL values for different data freshness requirements
- Implementing Redis caching patterns: sorted sets, pub/sub invalidation, Lua scripts
- Configuring Cache-Control headers, ETags, and CDN behavior
- Preventing cache stampedes via locking, probabilistic early expiry, or background refresh
- Cache warming strategies for cold-start scenarios
- Multi-tier cache design (in-memory L1, Redis L2, CDN L3)
❌ NOT for:
- Database-internal query plan caching (handled by the database)
- Python
functools.lru_cache/ JavaScript memoize utilities (pure function memoization) - CPU branch prediction or hardware cache tuning
- Session storage (use dedicated session skill)
---
Which Caching Pattern?
flowchart TD
Q1{Who writes to cache?} --> WA[Application writes]
Q1 --> WC[Cache writes automatically]
WA --> Q2{When does the cache get populated?}
Q2 -->|On read miss| CA[Cache-Aside\n'Lazy loading']
Q2 -->|On every write| WT[Write-Through\n'Eager write']
WC --> Q3{Sync or async write-back?}
Q3 -->|Sync — write completes when cache updates| WT
Q3 -->|Async — write returns fast, flush later| WB[Write-Behind\n'Write-back']
CA --> N1{Is stale data OK\nfor a short period?}
N1 -->|Yes| CA_USE[Use cache-aside\nwith TTL expiry]
N1 -->|No| INVAL[Add explicit invalidation\nor use write-through]
WT --> NOTE2[Good for read-heavy data\nthat changes infrequently]
WB --> NOTE3[Good for write-heavy workloads\nRisk: data loss on crash]---
Multi-Tier Cache Architecture
flowchart LR
USER[User Request] --> CDN{CDN / Edge Cache\nL3 — 100ms+ saved}
CDN -->|Cache hit| RESP[Response]
CDN -->|Cache miss| LB[Load Balancer]
LB --> APP[App Server]
APP --> L1{In-Process Cache\nL1 — ~0ms}
L1 -->|Hit| APP
L1 -->|Miss| REDIS{Redis\nL2 — 1-5ms}
REDIS -->|Hit| APP
REDIS -->|Miss| DB[(Database\n10-100ms)]
DB --> REDIS
REDIS --> APP
APP --> L1
APP --> CDN
APP --> RESP| Tier | Technology | Latency | Capacity | Shared? |
|---|---|---|---|---|
| L1: In-process | Node.js Map, Python dict, LRU-cache | ~0ms | Small (MB) | No — per instance |
| L2: Distributed | Redis, Memcached | 1-5ms | Large (GB) | Yes — all instances |
| L3: Edge/CDN | Cloudflare, Fastly, CloudFront | 10-100ms | Massive | Yes — globally |
Rule: Data mutates in one place first. Invalidation flows outward: DB → Redis → CDN. Never skip tiers in invalidation.
---
Cache-Aside Pattern (Most Common)
Application manages cache explicitly. On read: check cache, if miss fetch from DB, populate cache, return. On write: update DB, delete cache entry.
class UserCache {
private redis: Redis;
private readonly TTL_SECONDS = 300; // 5 minutes
async getUser(userId: string): Promise<User> {
const key = `user:${userId}`;
// 1. Check cache
const cached = await this.redis.get(key);
if (cached) return JSON.parse(cached);
// 2. Cache miss — fetch from source
const user = await db.users.findById(userId);
if (!user) throw new NotFoundError('User', userId);
// 3. Populate cache
await this.redis.setex(key, this.TTL_SECONDS, JSON.stringify(user));
return user;
}
async updateUser(userId: string, data: Partial<User>): Promise<User> {
const user = await db.users.update(userId, data);
// 4. Invalidate — delete, don't update
// Updating in cache risks race conditions; let the next read repopulate
await this.redis.del(`user:${userId}`);
return user;
}
}When invalidation deletes vs overwrites: Delete is almost always correct. Overwriting in cache after a write creates a race: another request may have fetched the old value between your DB write and your cache write. Delete forces the next reader to fetch fresh.
---
Write-Through Pattern
Every write goes to cache and DB synchronously. Cache is always populated. Good for data that is written once and read many times.
async function createProduct(data: CreateProductInput): Promise<Product> {
// Write to DB first (source of truth)
const product = await db.products.create(data);
// Immediately populate cache — no future cache miss for this product
const key = `product:${product.id}`;
await redis.setex(key, 3600, JSON.stringify(product));
// Also invalidate list caches that include this product
await redis.del('products:list:*'); // pattern delete via SCAN, see redis-patterns.md
return product;
}Trade-off: Higher write latency (two writes per operation). Wasted cache space for items that are never read again after creation. Best for data with high read:write ratio.
---
TTL Design
TTL is not a cache invalidation strategy — it is a staleness budget. Design TTLs based on data volatility and acceptable staleness:
| Data Type | TTL | Rationale |
|---|---|---|
| User session token | Match session expiry | Security requirement |
| User profile (name, avatar) | 5-15 minutes | Changes rarely; short enough for responsiveness |
| Product catalog | 1-4 hours | Changes occasionally; acceptable lag |
| Inventory counts | 30 seconds | Changes frequently; short but not zero |
| Exchange rates | 60 seconds | Regulatory; must not be too stale |
| Static config / feature flags | 60 seconds + pub/sub invalidation | Needs push invalidation on change |
| Computed aggregates (daily stats) | Until next computation | Explicit invalidation on recalculate |
TTL jitter: When many keys have the same TTL, they expire simultaneously, causing a thundering herd. Add random jitter:
const jitter = Math.floor(Math.random() * 60); // 0-60 seconds
await redis.setex(key, baseTtl + jitter, value);---
Cache Stampede Prevention
A stampede (also: dog-pile, thundering herd) occurs when many requests simultaneously miss an expired cache key and all rush to compute or fetch the value.
Strategy 1: Probabilistic Early Expiry (XFetch)
Re-fetch before expiry with probability proportional to how close the key is to expiring:
async function getWithEarlyExpiry<T>(
key: string,
fetcher: () => Promise<T>,
ttlSeconds: number,
beta = 1.0
): Promise<T> {
const entry = await redis.get(key + ':meta');
if (entry) {
const { value, expiresAt, fetchDurationMs } = JSON.parse(entry);
const now = Date.now();
const ttlRemaining = expiresAt - now;
// Fetch early if within probabilistic window
const shouldRefetch = ttlRemaining < beta * fetchDurationMs * Math.log(Math.random());
if (!shouldRefetch) return value;
}
// Fetch and cache
const start = Date.now();
const value = await fetcher();
const fetchDurationMs = Date.now() - start;
const expiresAt = Date.now() + ttlSeconds * 1000;
await redis.setex(key + ':meta', ttlSeconds, JSON.stringify({ value, expiresAt, fetchDurationMs }));
return value;
}Strategy 2: Mutex Lock on Miss
Only one worker recomputes the value; others wait on the lock or return stale data:
async function getWithLock<T>(
key: string,
fetcher: () => Promise<T>,
ttl: number
): Promise<T> {
const cached = await redis.get(key);
if (cached) return JSON.parse(cached);
const lockKey = `lock:${key}`;
const lockAcquired = await redis.set(lockKey, '1', 'NX', 'PX', 5000); // 5s TTL
if (!lockAcquired) {
// Another worker is computing — poll briefly then return stale or throw
await sleep(100);
const retried = await redis.get(key);
if (retried) return JSON.parse(retried);
throw new Error('Cache unavailable');
}
try {
const value = await fetcher();
await redis.setex(key, ttl, JSON.stringify(value));
return value;
} finally {
await redis.del(lockKey);
}
}Consult references/redis-patterns.md for the Lua-atomic version of this lock (prevents lock release by wrong client).
---
Anti-Patterns
Anti-Pattern: Cache Everything Forever
Novice: "Caching makes things fast. Set TTL to 0 (no expiry) or a year to maximize cache hit rate."
Expert: Unbounded caches are memory leaks with extra steps. They also guarantee stale data — users see prices, permissions, and content from months ago. Production incidents traced to "why is this user seeing the old plan limit" are almost always cache-forever bugs.
// Wrong — no expiry means the cache grows forever
await redis.set(`user:${id}`, JSON.stringify(user)); // no TTL
// Right — every cache entry has a maximum lifetime
await redis.setex(`user:${id}`, 300, JSON.stringify(user)); // 5 minutesPython equivalent:
# Wrong
redis.set(f"user:{id}", json.dumps(user))
# Right
redis.setex(f"user:{id}", 300, json.dumps(user))Detection: redis.set(key, value) without EX/PX/EXAT options. Redis TTL key returning -1 for cache keys. Memory growth over time with no plateau.
Timeline: This has always been wrong, but the Redis default of no-expiry makes it easy to do accidentally. Redis 7.0 (2022) introduced key eviction policies as default, reducing severity — but you still get stale data.
---
Anti-Pattern: No Invalidation Strategy
Novice: "I'll set a short TTL and the stale data problem solves itself."
Expert: TTL-only invalidation means every change to data has a propagation delay equal to the TTL. For some data (user roles, permissions, prices after a sale ends) that lag is unacceptable. Worse: this creates an implicit contract that is never documented, and teams later increase the TTL for performance without realizing they just made the staleness window much larger.
// Problem: user loses admin role, but can still access admin routes for 5 minutes
await redis.setex(`user:permissions:${id}`, 300, JSON.stringify(permissions));
// Right: invalidate explicitly on change
async function revokeAdminRole(userId: string) {
await db.userRoles.delete(userId, 'admin');
await redis.del(`user:permissions:${userId}`); // immediate invalidation
// Also publish to notify other app instances to clear L1 caches
await redis.publish('permissions:invalidated', userId);
}LLM mistake: LLMs frequently omit invalidation logic in code generation because it is invisible in simple cache-aside examples. Every tutorial shows "set on write," few show "delete on update."
Detection: Cache sets with no corresponding deletes in write paths. TTL as the only eviction mechanism for user-controlled data (roles, permissions, settings). No DEL, UNLINK, or pub/sub events in the codebase's update handlers.
---
References
references/redis-patterns.md— Consult for Redis-specific patterns: sorted sets for leaderboards, Lua atomic operations, pub/sub cache invalidation, SCAN-based key deletion, pipeline batchingreferences/http-caching.md— Consult for browser caching: Cache-Control directives, ETags, Vary headers, CDN configuration, service worker caching strategies
HTTP Caching Reference
Consult this file for browser caching, Cache-Control header design, ETags, CDN configuration, and service worker caching strategies.
---
Cache-Control Directive Reference
The Cache-Control header controls caching behavior at every layer: browser, CDN, and proxies.
Most Important Directives
| Directive | Meaning | Example Use |
|---|---|---|
max-age=N | Cache for N seconds | Static assets: max-age=31536000 |
s-maxage=N | CDN cache duration (overrides max-age for shared caches) | s-maxage=3600 for API responses |
no-cache | Must revalidate with server before serving (misleading name — does NOT skip caching) | HTML pages that change often |
no-store | Never store in any cache | Sensitive data: account pages, payment flows |
private | Browser may cache, but CDN must not | Personalized content |
public | Any cache (including CDN) may store | Cacheable API responses |
immutable | Tells browser the content will never change — skip revalidation during max-age | Content-hashed assets |
stale-while-revalidate=N | Serve stale while fetching fresh in background | Good for non-critical UI data |
stale-if-error=N | Serve stale if origin returns 5xx | Resilience: use cached version during outages |
must-revalidate | Do not serve stale even if origin is unavailable | Financial data, strict freshness |
Common Patterns
# Static assets with content hashing (main.a1b2c3.js)
# Use the longest possible TTL — the hash ensures cache-busting on change
Cache-Control: public, max-age=31536000, immutable
# HTML pages — always check for updates, but serve instantly from cache
Cache-Control: no-cache
# API response that is the same for all users (public)
# CDN caches for 1 hour; browser caches for 5 minutes
Cache-Control: public, max-age=300, s-maxage=3600
# Personalized API response (per-user) — browser only, CDN must not cache
Cache-Control: private, max-age=60
# Sensitive pages — no caching at any layer
Cache-Control: no-store, no-cache, must-revalidate
# Non-critical data: serve stale content while refreshing in background
Cache-Control: public, max-age=60, stale-while-revalidate=3600
# Resilience: serve stale if origin is down (for up to 24 hours)
Cache-Control: public, max-age=3600, stale-if-error=86400---
ETags and Conditional Requests
ETags enable efficient revalidation: the browser asks "has this changed?" instead of downloading the full response again.
How ETags Work
Browser → GET /api/product/123 → Server
Server ← 200 OK + ETag: "abc123" + full body ← Server
Browser → GET /api/product/123 + If-None-Match: "abc123" → Server
Server ← 304 Not Modified (no body, just headers) ← Server
OR
Server ← 200 OK + ETag: "def456" + new full body ← ServerServer Implementation (Node.js/Express)
import crypto from 'crypto';
import { Request, Response } from 'express';
// Weak ETag: based on content hash
function generateETag(data: unknown): string {
const hash = crypto.createHash('md5').update(JSON.stringify(data)).digest('hex');
return `"${hash}"`;
}
async function getProductHandler(req: Request, res: Response) {
const product = await db.products.findById(req.params.id);
if (!product) return res.status(404).json({ error: 'Not found' });
const etag = generateETag(product);
// Check if client has current version
if (req.headers['if-none-match'] === etag) {
return res.status(304).end(); // Not Modified — no body sent
}
res
.setHeader('ETag', etag)
.setHeader('Cache-Control', 'public, max-age=60')
.json(product);
}Last-Modified Alternative
Use Last-Modified + If-Modified-Since when content has a reliable modification timestamp:
const lastModified = product.updatedAt.toUTCString();
if (req.headers['if-modified-since'] === lastModified) {
return res.status(304).end();
}
res
.setHeader('Last-Modified', lastModified)
.setHeader('Cache-Control', 'public, max-age=300')
.json(product);ETag vs Last-Modified: ETags are more reliable (timestamp precision is 1 second; ETags detect any change). Use ETags for most cases. Last-Modified is useful when exact modification timestamps are meaningful to the application.
---
Vary Header
The Vary header tells CDNs which request headers affect the response. Without Vary, the CDN may serve a cached English response to a French-speaking user.
# Serve different responses based on Accept-Encoding (compression)
Vary: Accept-Encoding
# Serve different responses based on language
Vary: Accept-Language
# Serve different responses based on content type negotiation
Vary: Accept
# Multiple: all of these must match for a cache hit
Vary: Accept-Encoding, Accept-LanguageWarning: Vary: Cookie or Vary: Authorization effectively disable CDN caching (every user gets a different cache entry). Use Cache-Control: private for user-specific responses instead.
---
CDN Cache Configuration
Cloudflare
// Cloudflare Workers — fine-grained cache control at the edge
export default {
async fetch(request: Request): Promise<Response> {
const cache = caches.default;
const cacheKey = new Request(request.url, request);
// Check edge cache first
let response = await cache.match(cacheKey);
if (response) {
return response;
}
// Forward to origin
response = await fetch(request);
// Cache successful GET responses
if (request.method === 'GET' && response.status === 200) {
const cacheResponse = new Response(response.body, response);
cacheResponse.headers.set('Cache-Control', 'public, max-age=300, s-maxage=3600');
// Do not await — cache in background, respond immediately
request.ctx.waitUntil(cache.put(cacheKey, cacheResponse.clone()));
return cacheResponse;
}
return response;
}
};Cache Purging (Cloudflare API)
async function purgeCloudflareCache(urls: string[]): Promise<void> {
const response = await fetch(
`https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/purge_cache`,
{
method: 'POST',
headers: {
'Authorization': `Bearer ${CLOUDFLARE_API_TOKEN}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ files: urls }),
}
);
if (!response.ok) {
throw new Error(`Cache purge failed: ${await response.text()}`);
}
}
// Purge product page after update
await purgeCloudflareCache([
`https://example.com/products/${productId}`,
`https://api.example.com/api/products/${productId}`,
]);Next.js: On-Demand Revalidation
// app/api/revalidate/route.ts
import { revalidatePath, revalidateTag } from 'next/cache';
import { NextRequest, NextResponse } from 'next/server';
export async function POST(req: NextRequest) {
const { secret, path, tag } = await req.json();
if (secret !== process.env.REVALIDATION_SECRET) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
if (path) revalidatePath(path);
if (tag) revalidateTag(tag);
return NextResponse.json({ revalidated: true });
}
// Server Component: tag fetches for granular invalidation
async function ProductPage({ id }: { id: string }) {
const product = await fetch(`/api/products/${id}`, {
next: {
revalidate: 3600, // ISR: revalidate every hour
tags: [`product:${id}`] // or purge by tag
}
});
// ...
}---
Service Worker Caching
Service workers intercept network requests and can serve from a cache, providing offline support and background sync.
Strategy Selection
| Strategy | When to Use | Trade-off |
|---|---|---|
| Cache First | Static assets, fonts, app shell | Stale risk; fast |
| Network First | API data, dynamic content | Slow on bad network |
| Stale-While-Revalidate | Content that changes but can be slightly stale | Instant + fresh |
| Network Only | Payments, sensitive forms | No offline support |
| Cache Only | Fully offline app after install | No updates after cache |
Implementation (Workbox)
// service-worker.js (using Workbox)
import { registerRoute } from 'workbox-routing';
import { CacheFirst, NetworkFirst, StaleWhileRevalidate } from 'workbox-strategies';
import { ExpirationPlugin } from 'workbox-expiration';
import { BackgroundSyncPlugin } from 'workbox-background-sync';
// App shell and static assets: cache first, long TTL
registerRoute(
({ request }) => request.destination === 'script' || request.destination === 'style',
new CacheFirst({
cacheName: 'static-assets',
plugins: [
new ExpirationPlugin({ maxAgeSeconds: 30 * 24 * 60 * 60 }), // 30 days
],
})
);
// API data: network first with offline fallback
registerRoute(
({ url }) => url.pathname.startsWith('/api/'),
new NetworkFirst({
cacheName: 'api-cache',
plugins: [
new ExpirationPlugin({
maxEntries: 50,
maxAgeSeconds: 5 * 60, // 5 minutes offline fallback
}),
],
networkTimeoutSeconds: 3, // fall back to cache after 3s
})
);
// Images: stale-while-revalidate
registerRoute(
({ request }) => request.destination === 'image',
new StaleWhileRevalidate({
cacheName: 'images',
plugins: [
new ExpirationPlugin({
maxEntries: 100,
maxAgeSeconds: 7 * 24 * 60 * 60, // 7 days
}),
],
})
);
// Background sync for failed POST requests
const bgSyncPlugin = new BackgroundSyncPlugin('formQueue', {
maxRetentionTime: 24 * 60, // retry for up to 24 hours
});
registerRoute(
({ url, request }) => url.pathname === '/api/submit' && request.method === 'POST',
new NetworkOnly({ plugins: [bgSyncPlugin] }),
'POST'
);---
Cache-Busting Strategies
| Method | Mechanism | Pros | Cons |
|---|---|---|---|
| Content hashing | main.a1b2c3.js | Perfect cache busting | Requires build pipeline |
| Query string | main.js?v=20260301 | Simple | Some CDNs ignore query strings |
| Path versioning | /v2/api/products | Works everywhere | Breaking API change required |
| Immutable + redeploy | Deploy new URL, purge CDN | Clean | Requires CDN purge API |
Recommendation for static assets: Use content hashing (webpack, Vite, Rollup all support this). Set Cache-Control: public, max-age=31536000, immutable. Cache busting is automatic — changing the file changes the hash, changes the URL.
Recommendation for API responses: Use ETags + no-cache for HTML; use short TTLs + stale-while-revalidate for API data; use CDN cache tags and programmatic purge on mutations.
---
Debugging HTTP Caching
# Check response headers (what the server sends)
curl -I https://example.com/api/products/123
# Check if CDN served from cache (look for Cf-Cache-Status, X-Cache headers)
curl -I -H "User-Agent: debug" https://example.com/api/products/123
# Cloudflare: Cf-Cache-Status: HIT | MISS | BYPASS | EXPIRED
# Force a fresh fetch bypassing browser cache (devtools)
# Chrome: Ctrl+Shift+R (hard reload) or open devtools and right-click reload
# Or: Fetch API with cache: 'no-store'
const response = await fetch('/api/data', { cache: 'no-store' });Common Debugging Checklist
Cf-Cache-Status: BYPASS→ Request has authorization header or cookie that Cloudflare is configured to bypass onCache-Control: no-storeon a CDN-cached URL → CDN may be ignoring it; check CDN page rules- Browser showing stale data after purge → Check if browser has its own cached copy (hard reload or devtools > Disable cache)
Vary: *→ Disables caching entirely for that response; find where this header is being set- 304 Not Modified not working → ETag or Last-Modified header missing from server response
Redis Caching Patterns Reference
Consult this file for Redis-specific implementation patterns: data structures, atomic operations, pub/sub invalidation, key management, and pipeline optimization.
---
Key Naming Conventions
{service}:{entity}:{id}:{field?}
Examples:
user:profile:u_123
user:permissions:u_123
product:detail:p_456
product:list:category:electronics:page:1
session:tok_abc123
rate_limit:ip:192.168.1.1
lock:job:email-batch:u_123Always prefix with a namespace. Enables:
- Pattern-based deletion with SCAN
- Memory analysis per domain
- Redis Cluster routing by hash slot (prefix before
{is ignored for slot calculation)
---
Common Data Structure Patterns
String: Simple cache entry
// Set with TTL (always use TTL for cache entries)
await redis.setex(`user:profile:${id}`, 300, JSON.stringify(user));
// Conditional set — only if not exists (used for locks and idempotency)
const lockSet = await redis.set(`lock:${key}`, clientId, 'NX', 'PX', 5000);
// Get and refresh TTL in one call (sliding expiry)
const pipeline = redis.pipeline();
pipeline.get(`session:${token}`);
pipeline.expire(`session:${token}`, 3600);
const [[, value]] = await pipeline.exec();Hash: Object with partial updates
Use hashes when you need to update individual fields without fetching and re-serializing the entire object.
// Store user as hash — fields are individually settable
await redis.hset(`user:profile:${id}`, {
name: user.name,
email: user.email,
plan: user.plan,
updatedAt: Date.now().toString(),
});
await redis.expire(`user:profile:${id}`, 3600);
// Update only the plan field — no need to fetch the full user
await redis.hset(`user:profile:${id}`, 'plan', 'enterprise');
// Get all fields
const profile = await redis.hgetall(`user:profile:${id}`);
// Get specific fields
const [name, plan] = await redis.hmget(`user:profile:${id}`, 'name', 'plan');When to use hash vs JSON string:
- Hash: entity with many fields, frequent partial updates (user profile, config)
- JSON string: deeply nested object, always read/written atomically, needs JSON querying
Sorted Set: Ranked lists and leaderboards
// Add score and member (score is float, used for ranking)
await redis.zadd('leaderboard:weekly', score, userId);
// Get top 10 with scores (highest first)
const top10 = await redis.zrevrangebyscore(
'leaderboard:weekly',
'+inf',
'-inf',
'WITHSCORES',
'LIMIT', 0, 10
);
// Get user rank (0-indexed from highest)
const rank = await redis.zrevrank('leaderboard:weekly', userId);
// Sorted set as time-ordered event log (score = timestamp ms)
await redis.zadd('events:user:u_123', Date.now(), JSON.stringify(event));
// Get events in a time range
const recentEvents = await redis.zrangebyscore(
'events:user:u_123',
Date.now() - 86400_000, // last 24 hours
Date.now()
);
// Remove events older than 24 hours (sliding window)
await redis.zremrangebyscore('events:user:u_123', 0, Date.now() - 86400_000);Set: Membership and deduplication
// Track which users have seen a feature announcement
await redis.sadd(`announcement:seen:ann_456`, userId);
await redis.expire(`announcement:seen:ann_456`, 86400 * 30); // 30 days
const hasSeen = await redis.sismember(`announcement:seen:ann_456`, userId);
// Intersection: users who are both premium AND have completed onboarding
await redis.sinterstore('eligible:campaign', 'users:premium', 'users:onboarded');---
Atomic Operations with Lua Scripts
Use Lua scripts when you need read-modify-write atomicity without explicit transactions. Lua scripts execute atomically on the Redis server — no other commands execute between script steps.
Token Bucket Rate Limiting (Lua)
-- rate_limit.lua
-- KEYS[1] = rate limit key
-- ARGV[1] = limit (max tokens)
-- ARGV[2] = refill rate per second
-- ARGV[3] = current timestamp (ms)
-- ARGV[4] = tokens requested
local key = KEYS[1]
local limit = tonumber(ARGV[1])
local refill_rate = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local requested = tonumber(ARGV[4])
local bucket = redis.call('HMGET', key, 'tokens', 'last_refill')
local tokens = tonumber(bucket[1]) or limit
local last_refill = tonumber(bucket[2]) or now
local elapsed = math.max(0, now - last_refill) / 1000
tokens = math.min(limit, tokens + elapsed * refill_rate)
if tokens >= requested then
tokens = tokens - requested
redis.call('HMSET', key, 'tokens', tokens, 'last_refill', now)
redis.call('PEXPIRE', key, math.ceil(limit / refill_rate) * 1000)
return 1 -- allowed
else
redis.call('HMSET', key, 'tokens', tokens, 'last_refill', now)
return 0 -- denied
endimport { readFileSync } from 'fs';
import { execFileSync } from 'child_process';
const rateLimitScript = readFileSync('./redis/rate_limit.lua', 'utf8');
async function checkRateLimit(identifier: string, limitPerSecond: number): Promise<boolean> {
const key = `rate_limit:${identifier}`;
const result = await redis.eval(
rateLimitScript,
1, // number of KEYS arguments
key, // KEYS[1]
limitPerSecond, // ARGV[1]: max tokens
limitPerSecond, // ARGV[2]: refill rate
Date.now(), // ARGV[3]: timestamp ms
1 // ARGV[4]: tokens requested
);
return result === 1;
}Atomic Cache-Aside with Lock (Lua)
Prevents stampede: acquires lock atomically if key is missing, so only one worker computes the value.
-- get_or_lock.lua
-- KEYS[1] = cache key, KEYS[2] = lock key
-- ARGV[1] = lock TTL ms, ARGV[2] = lock holder ID
-- Returns: {status, value}
-- status 1 = cache hit, value = cached data
-- status 2 = lock acquired by caller, value = false (caller must compute)
-- status 3 = lock contended, value = false (caller should wait and retry)
local cached = redis.call('GET', KEYS[1])
if cached then
return {1, cached}
end
local acquired = redis.call('SET', KEYS[2], ARGV[2], 'NX', 'PX', ARGV[1])
if acquired then
return {2, false}
else
return {3, false}
end---
Pub/Sub Cache Invalidation
Use Redis pub/sub to notify all app instances to clear their L1 (in-process) caches when data changes. Essential in multi-instance deployments.
// Publisher — runs when any data mutation succeeds
class CacheInvalidator {
async invalidate(entity: string, id: string): Promise<void> {
const channel = `cache:invalidate:${entity}`;
// Delete from Redis L2 directly (don't rely on pub/sub for L2)
await redis.del(`${entity}:${id}`);
// Notify all instances to clear their L1 caches
await redis.publish(channel, id);
}
}
// Subscriber — runs in each app instance at startup
class CacheSubscriber {
private l1Cache = new Map<string, unknown>();
private subscriber: Redis;
async start(): Promise<void> {
this.subscriber = redis.duplicate();
await this.subscriber.subscribe('cache:invalidate:user');
await this.subscriber.subscribe('cache:invalidate:product');
this.subscriber.on('message', (channel: string, id: string) => {
const entity = channel.split(':')[2]; // e.g. 'user'
this.l1Cache.delete(`${entity}:${id}`);
});
}
}Pattern rule: Every app instance subscribes at startup. Pub/sub is fire-and-forget — always delete from Redis directly AND publish. If pub/sub delivery fails, L1 caches become stale but L2 Redis is already clean.
---
SCAN: Safe Key Enumeration
KEYS pattern is O(N) and blocks the Redis event loop — never use in production. Use SCAN for iterative, non-blocking key enumeration:
async function deleteKeysByPattern(pattern: string): Promise<number> {
let cursor = '0';
let deleted = 0;
do {
const [nextCursor, keys] = await redis.scan(cursor, 'MATCH', pattern, 'COUNT', 100);
cursor = nextCursor;
if (keys.length > 0) {
// UNLINK performs async background deletion — prefer over DEL for large values
await redis.unlink(...keys);
deleted += keys.length;
}
} while (cursor !== '0');
return deleted;
}
// Usage: invalidate all pages of a product listing
await deleteKeysByPattern('product:list:*');COUNT hint: COUNT 100 tells Redis how many keys to check per iteration. It is a hint, not a guarantee. Increase for large keyspaces; decrease to reduce per-iteration latency impact.
---
Pipeline Batching
Pipeline sends multiple commands in one network round trip. Use when reading or writing multiple independent keys:
// Without pipeline: 3 round trips
const user = await redis.get('user:u_1');
const product = await redis.get('product:p_2');
const session = await redis.get('session:s_3');
// With pipeline: 1 round trip
const pipe = redis.pipeline();
pipe.get('user:u_1');
pipe.get('product:p_2');
pipe.get('session:s_3');
const results = await pipe.exec();
// results[0] = [error|null, userValue], results[1] = [error|null, productValue], etc.
// Batch cache population from a list of entities
const batchPipe = redis.pipeline();
for (const item of items) {
batchPipe.setex(`item:${item.id}`, 300, JSON.stringify(item));
}
await batchPipe.exec();Pipeline vs Transaction (MULTI/EXEC): Pipeline is not atomic — other commands can interleave. MULTI/EXEC is atomic. Use pipelines for throughput; use Lua scripts or MULTI/EXEC for atomicity.
---
Redis Memory Management
Eviction Policies
| Policy | Behavior | When to Use |
|---|---|---|
noeviction | Return error when memory full | Never for pure caches |
allkeys-lru | Evict least recently used key | General-purpose cache |
volatile-lru | Evict LRU keys that have a TTL set | Mixed cache + persistent data |
allkeys-lfu | Evict least frequently used key | Workloads with hot/cold access patterns |
volatile-ttl | Evict keys with shortest remaining TTL | When freshness is most important |
Recommended default for pure caching: allkeys-lru with maxmemory explicitly set.
# redis.conf
maxmemory 2gb
maxmemory-policy allkeys-lru
maxmemory-samples 10Key Metrics to Monitor
# Memory usage and fragmentation
redis-cli INFO memory | grep -E "used_memory_human|mem_fragmentation_ratio"
# Hit rate: target >90%
redis-cli INFO stats | grep -E "keyspace_hits|keyspace_misses"
# hit_rate = keyspace_hits / (keyspace_hits + keyspace_misses)
# Find slow commands (>10ms by default threshold)
redis-cli SLOWLOG GET 25
# Key count and database stats
redis-cli DBSIZE
redis-cli INFO keyspace---
Connection Configuration (ioredis)
import Redis from 'ioredis';
const redis = new Redis({
host: process.env.REDIS_HOST,
port: 6379,
password: process.env.REDIS_PASSWORD,
tls: process.env.NODE_ENV === 'production' ? {} : undefined,
// Auto-batch concurrent commands into pipelines
enableAutoPipelining: true,
// Do not connect until first command is issued
lazyConnect: true,
// Retry on transient connection failures
maxRetriesPerRequest: 3,
retryStrategy(times: number) {
if (times > 3) return null; // stop after 3 attempts
return Math.min(times * 50, 500); // exponential: 50, 100, 150ms
},
connectTimeout: 5000,
commandTimeout: 2000,
});
redis.on('error', (err) => logger.error('Redis error', { err }));
redis.on('reconnecting', () => logger.warn('Redis reconnecting'));