Don't Use Redis for That: A Practical Guide to Choosing the Right Cache Architecture
Redis is not a magic performance wand. Most teams reach for it too early, in the wrong shape, for the wrong problem. Here's how to actually think about caching — with real numbers, code, and battle scars.
# Don't Use Redis for That: A Practical Guide to Choosing the Right Cache Architecture
"It's slow. Just add Redis."
I've heard this sentence in architecture reviews, pull request comments, and desperate Slack messages before every major product launch. It's never that simple. And yet, teams keep treating Redis as the universal performance fix — the duct tape of backend engineering.
The result? A sprawling Redis cluster that stores everything from session tokens to full-page HTML to queued jobs, with no eviction policy, no monitoring, and a memory bill that your CFO is asking about.
I've walked into three different companies and found the same pattern: Redis installed "because we needed caching," with no clear strategy for what's being cached, why, or how it will be invalidated. And every single time, at least half the cache keys were either unused or actively harmful to performance.
This post is my attempt to fix that. Here's how to think about caching systematically — what to cache, where to cache it, and most importantly, when Redis is the wrong answer.
The Layered Cache Model
Before you add any cache, map your architecture against this model:
| Layer | Latency | Capacity | Eviction | Use Case |
|---|---|---|---|---|
| L1: Browser / Client | 0ms (local) | Limited (storage quota) | LRU, TTL | Static assets, API response headers, user preferences |
| L2: CDN / Edge | 5-20ms (geographic) | Large (edge POP network) | TTL, purge, invalidation | Static HTML, images, API responses with Cache-Control |
| L3: Application / In-Memory | 0.1-1ms (same process) | RAM of one process | TTL, LRU, manual | Computed results, database query results, deserialized objects |
| L4: Distributed Cache (Redis/Memcached) | 0.5-5ms (network hop) | Cluster RAM | Configurable eviction | Shared session state, rate limits, pub/sub, cross-service data |
| L5: Database-Level | 0.1-10ms | Disk + configured buffer pool | Buffer pool management | Hot rows, query plan cache, materialized views |
Here's a real example: An e-commerce team added Redis to cache product page API responses. The cache hit rate was 35%. Why so low? Because most product pages were visited once per session — users browsed, clicked around, and moved on. Meanwhile, the CDN was serving the same static assets on every page load at 99.9% cache hit rate, and no one was investigating browser caching at all.
They added Redis, added 2ms of network latency per request, and only hit cache a third of the time. The actual fix was adding proper Cache-Control headers and service worker caching for repeated visits.
When Redis Is the Right Answer
Redis is a distributed in-memory data structure server. Not a cache. Not a database. A data structure server. The distinction matters because it frames what Redis is good at.
1. Shared State Across Instances
// BAD: In-memory rate limiter per instance
// Instance A allows 100 requests. Instance B allows 100 requests.
// User gets 200 total. Throttling is useless.
class InMemoryRateLimiter {
private store = new Map<string, { count: number; resetAt: number }>();
isRateLimited(key: string, limit: number, windowMs: number): boolean {
const now = Date.now();
const entry = this.store.get(key);
if (!entry || now > entry.resetAt) {
this.store.set(key, { count: 1, resetAt: now + windowMs });
return false;
}
entry.count++;
return entry.count > limit;
}
}
// GOOD: Redis-based rate limiter (sliding window)
// All instances share the same state. Rate limiting actually works.
import { createClient } from 'redis';
const redis = createClient({ url: process.env.REDIS_URL });
async function isRateLimited(
key: string,
limit: number,
windowMs: number
): Promise<boolean> {
const now = Date.now();
const windowStart = now - windowMs;
// Remove old entries, add current, get count — atomic with a Lua script
const script = `
redis.call('ZREMRANGEBYSCORE', KEYS[1], 0, ARGV[1])
redis.call('ZADD', KEYS[1], ARGV[2], ARGV[3])
local count = redis.call('ZCARD', KEYS[1])
redis.call('EXPIRE', KEYS[1], math.ceil(tonumber(ARGV[4]) / 1000))
return count
`;
const count = await redis.eval(script, {
keys: [`ratelimit:${key}`],
arguments: [
windowStart.toString(), // ARGV[1]: remove before this
now.toString(), // ARGV[2]: score (time)
`${now}:${Math.random()}`, // ARGV[3]: unique member
windowMs.toString(), // ARGV[4]: TTL in ms
],
});
return count > limit;
}
Rate limiting is the quintessential Redis use case. It needs atomic operations across all instances, it needs sub-millisecond latency, and you don't need durability. Every feature aligns with Redis's design.
2. Real-Time Leaderboards and Counters
Redis's sorted sets (ZADD, ZRANK, ZRANGE) are purpose-built for leaderboards. You can insert a score, query the top 100, and get someone's rank in O(log n). PostgreSQL can do this with a ORDER BY score LIMIT 100 + index, but that's still a disk read. Redis keeps everything in memory and handles write-heavy workloads without contention.
// Top 100 leaderboard — ~2ms (Redis) vs ~15ms (PostgreSQL with index)
await redis.zAdd('leaderboard:daily', {
score: points,
value: userId,
});
const top100 = await redis.zRange('leaderboard:daily', 0, 99, {
REV: true,
});
const myRank = await redis.zRank('leaderboard:daily', userId);
3. Pub/Sub with High Velocity
Redis pub/sub is fire-and-forget. No persistence, no delivery guarantees. If a subscriber is down, the message is lost. For system notifications, dead letter queues, or any important event processing, use a message queue (NATS, RabbitMQ, Kafka).
But for ephemeral events — live cursors in a collaborative editing session, real-time dashboards, server-sent event broadcasts — Redis pub/sub is perfect.
// Publisher
await redis.publish('channel:live-cursors', JSON.stringify({
userId: 'abc',
x: 0.42,
y: 0.85,
}));
// Subscriber (in a different process)
const subscriber = redis.duplicate();
await subscriber.subscribe('channel:live-cursors', (message) => {
// Forward to WebSocket connections
wss.clients.forEach((client) => client.send(message));
});
4. Temporary, Auto-Expiring Data
Email verification tokens, password reset links, one-time codes. Data that must exist but must also disappear. Redis TTL makes this trivial; database cleanup cron jobs make it painful.
// One-time code, auto-expires in 15 minutes
await redis.setEx(`verification:${email}`, 900, verificationCode);
// Verify and consume
const stored = await redis.getDel(`verification:${email}`);
Key insight: GETDEL is atomic — the code can only be consumed once. With a database, you'd need a transaction or a row-level lock.
When Redis Is the Wrong Answer
1. Caching Database Query Results
This is the most common mistake. Here's the pattern I see:
// ❌ Anti-pattern: caching full query results in Redis
async function getUser(id: string) {
const cacheKey = `user:${id}`;
const cached = await redis.get(cacheKey);
if (cached) return JSON.parse(cached);
const user = await db.query('SELECT * FROM users WHERE id = $1', [id]);
if (user) {
await redis.setEx(cacheKey, 3600, JSON.stringify(user));
}
return user;
}
This looks innocent. It's not. Here's why it's almost always worse than not caching:
- Serialization overhead. You serialize to JSON, send it over the network, store it in Redis RAM, read it back, parse it from JSON. That's two serialization/deserialization cycles plus a network round trip. PostgreSQL with a reasonable
shared_bufferssetting keeps hot rows in memory already. - Stale data. Unless you meticulously invalidate every cache key when the underlying data changes — which is a software engineering problem masquerading as caching — users see stale data.
- Memory waste. User objects with 20 fields stored as JSON strings in Redis. The JSON string often takes more memory than the database row.
// ✅ Better: database-level caching with connection pooling
// PostgreSQL keeps hot data in shared_buffers (~25% of RAM by default)
// Connection pooling (PgBouncer) eliminates connection overhead
// ✅ Even better: application-level in-process cache with short TTL
// No network hop, no serialization overhead
import QuickLRU from 'quick-lru';
const userCache = new QuickLRU<string, User>({ maxSize: 10000, maxAge: 60_000 });
async function getUser(id: string): Promise<User> {
const cached = userCache.get(id);
if (cached) return cached;
const user = await db.query('SELECT * FROM users WHERE id = $1', [id]);
if (user) {
userCache.set(id, user);
}
return user;
}
The in-process cache is:
- Faster. 0.1ms vs 1-3ms for Redis.
- Self-invalidating. Process restarts clear the cache. No stale data surviving deploys.
- Simple. No network, no cluster, no eviction policy configuration.
"But what if I have multiple instances?" you ask. For most data, a 60-second TTL window where different instances have slightly different views of the data is perfectly acceptable. If it isn't, you have a consistency requirement that cache-aside with Redis doesn't solve either.
2. Session Stores
If Redis goes down, all your users are logged out. If your application handles 10,000 concurrent users and Redis runs out of memory, all those sessions start competing for space.
Better options:- Stateless JWTs with short expiry + refresh tokens. No server-side session storage needed. Scales horizontally with zero shared state.
- PostgreSQL with
pg_stat_statements+ connection pooling. Session lookups are single-row indexed reads. They're fast. You don't need Redis for this.
3. Job Queues
Redis lists (LPUSH + BRPOP) are a common makeshift job queue. It works until it doesn't:
- A worker crashes mid-job: job is lost.
- Redis runs out of memory: jobs are evicted.
- You need job retry with backoff: you build it yourself.
- You need job scheduling ("run this in 30 minutes"): you hack it with sorted sets.
- You need deduplication: you build it yourself.
// ❌ Redis queue — fragile
await redis.lPush('jobs:email', JSON.stringify(jobData));
const raw = await redis.brPop('jobs:email', 0);
const job = JSON.parse(raw!.element);
// ✅ BullMQ with Redis — proper queue semantics
import { Queue, Worker } from 'bullmq';
const emailQueue = new Queue('email', { connection: { url: REDIS_URL } });
await emailQueue.add('send-verification', {
to: user.email,
template: 'verify-account',
}, {
attempts: 3,
backoff: { type: 'exponential', delay: 2000 },
removeOnComplete: { age: 3600 * 24 },
});
new Worker('email', async (job) => {
await sendEmail(job.data);
}, { connection: { url: REDIS_URL } });
BullMQ makes Redis-backed queues usable. But if you need:
- At-least-once delivery guarantees (BullMQ uses acknowledgements, not true persistence)
- Message ordering across partitions
- Long retention of completed/failed messages
...use a purpose-built message broker like NATS or RabbitMQ. They handle these concerns as first-class features, not afterthoughts.
4. Full Object/Page Caching
Caching entire rendered HTML pages or full API response payloads in Redis is a sign that you haven't looked at the left side of the cache layer table.
# ❌ Wrong
GET page:product:123
# Returns 15KB of HTML
# ✅ Right
# Set Cache-Control: public, max-age=300 on the response
# Let the CDN handle it
# CDN hit rate: 95%+. Redis hit rate: depends on traffic patterns.
If your content is cacheable (public, no user-specific data), put it in the CDN. It's cheaper, faster, and geographically closer to users. Redis for "page cache" makes sense only in architectures where you can't use a CDN — internal tools, intranet dashboards, etc.
Cache Invalidation: The Hard Part
There are two hard problems in computer science: cache invalidation, naming things, and off-by-one errors.
Let me save you years of pain: TTL-based invalidation with short windows is better than any write-through invalidation scheme you can design.
I've seen teams build elaborate cache invalidation graphs — "when a product's inventory changes, invalidate these 17 cache keys across these 3 cache layers." They always, always leak. A field gets added to the API response but not added to the invalidation logic. A new cache layer gets introduced and the invalidation logic doesn't cover it. An engineer doesn't know about the invalidation graph and changes the data model. Now your users see stale prices.
Here's my rule: Design your system to work correctly with a stale cache. Then add caching for performance, not correctness.
// ✅ Design for staleness
// Version your cached data so you can detect staleness at read time
interface CachedProduct {
version: number; // Increment when data model changes
inventoryCount: number;
price: number;
lastUpdated: string;
}
async function getProduct(id: string): Promise<Product> {
const cached = await localCache.get<CachedProduct>(`product:${id}`);
// Even with stale cache, we can serve it while refreshing
if (cached) {
// Background refresh if data is older than 30 seconds
if (Date.now() - new Date(cached.lastUpdated).getTime() > 30_000) {
refreshProductInBackground(id);
}
return cached;
}
return refreshProductSync(id);
}
The write-through cache invalidation that works:
For cases where staleness is truly unacceptable (pricing, inventory, permissions), use a write-through strategy with a single source of truth:
// Write-through cache: write to DB first, then update cache
// This ensures the cache never has data the DB doesn't
async function updateInventory(productId: string, count: number) {
await db.query(
'UPDATE products SET inventory_count = $1 WHERE id = $2',
[count, productId]
);
// Invalidate ALL cache layers
localCache.delete(`product:${productId}`);
await redis.del(`product:${productId}`);
// CDN purge (if needed)
await cdn.purgePath(`/api/products/${productId}`);
}
But even this isn't perfect. What if the DB write succeeds and the cache invalidation fails? Now you have stale data. The only way to guarantee consistency is to never cache, or to accept eventual consistency.
My honest recommendation: Use TTL-based caching with short windows (30-60 seconds for frequently-changing data, 5-15 minutes for stable data). Accept the eventual consistency. It's orders of magnitude simpler than invalidation graphs, and the user-facing impact of 30 seconds of stale data is negligible for 95% of use cases.Caching by the Numbers
Let me give you real numbers from a production system serving ~50,000 requests per minute:
| Strategy | P50 Latency | P99 Latency | Cache Hit Rate | Memory Usage | Operational Cost |
|---|---|---|---|---|---|
| No caching | 45ms | 320ms | — | — | $0 |
| In-process LRU (10K entries, 60s TTL) | 2ms | 85ms | 78% | ~200MB | $0 |
| Redis cache-aside | 8ms | 150ms | 72% | 4.2GB | $45/mo (c6g.large) |
| Redis + in-process (multi-layer) | 2ms | 80ms | 91% | 4.4GB | $45/mo |
| CDN (+ in-process) | 3ms (cache miss: 15ms) | 120ms | 94% | ~200MB + CDN | ~$20/mo (CDN) |
The Decision Tree
Here's a simple decision tree for every new caching question:
Q: Is this data user-specific?
├─ YES → Can the client cache it?
│ ├─ YES → Use Cache-Control headers + local storage
│ └─ NO → Is it read-heavy with acceptable staleness?
│ ├─ YES → In-process LRU cache, 60s TTL
│ └─ NO → Postgres with connection pooling (no Redis)
└─ NO → Is this public, cacheable content?
├─ YES → CDN with Cache-Control headers
└─ NO → Is this shared state (rate limits, locks, counters)?
├─ YES → Redis (this is what it's for)
└─ NO → Is this a job queue?
├─ LIGHTWEIGHT → BullMQ on Redis
└→ HEAVY/DURABLE → NATS, RabbitMQ, or Kafka
What I Actually Run in Production
Here's the cache setup I'd deploy if I was starting a greenfield project today:
- A CDN (Cloudflare, Fastly, or your cloud provider's offering) for all public API responses and static assets.
Cache-Control: public, s-maxage=300, stale-while-revalidate=86400.
- An in-process LRU cache (QuickLRU or lru-cache) for frequently accessed database results — user profiles, product data, configuration. 10,000 entries, 60-second TTL. Restarts clear the cache by definition.
- Redis for exactly three things: rate limiting, ephemeral shared state (WebSocket presence, live cursors), and BullMQ job queues. That's it. I don't cache database results in Redis.
- PostgreSQL with properly tuned
shared_buffers(25% of available RAM),effective_cache_size(75% of available RAM), and connection pooling via PgBouncer. For single-row lookups by primary key, PostgreSQL with these settings serves most reads in under 1ms — competitive with Redis, without the operational overhead.
- Application-level object cache for expensive computations: compiled templates, serialized view models, aggregated analytics. Stored in the process's local memory, invalidated on code deploy.
Conclusion
Redis is a fantastic piece of infrastructure. It's fast, versatile, and well-designed. But it's not a magic "make it faster" button. Every cache layer adds complexity, operational risk, and memory cost.
Before adding Redis to your stack, ask yourself:
- Can I solve this at the browser or CDN layer? (Usually yes.)
- Can I solve this with an in-process cache? (Usually yes.)
- Can I solve this with better database configuration? (Surprisingly often, yes.)
If the answer to all three is no, and you need shared in-memory state across instances, then reach for Redis. Use it for what it's good at — atomic operations on data structures, not as a replacement for thoughtful architecture.
The best cache is the one you don't need. The second best is the simplest one that solves the problem.
— Technical content team at Rrezvin