Back to Blog
EngineeringJuly 26, 2026·14 min

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.

CachingRedisPerformanceArchitectureDatabasesBackendDevOpsSystem Design

# 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:

LayerLatencyCapacityEvictionUse Case
L1: Browser / Client0ms (local)Limited (storage quota)LRU, TTLStatic assets, API response headers, user preferences
L2: CDN / Edge5-20ms (geographic)Large (edge POP network)TTL, purge, invalidationStatic HTML, images, API responses with Cache-Control
L3: Application / In-Memory0.1-1ms (same process)RAM of one processTTL, LRU, manualComputed results, database query results, deserialized objects
L4: Distributed Cache (Redis/Memcached)0.5-5ms (network hop)Cluster RAMConfigurable evictionShared session state, rate limits, pub/sub, cross-service data
L5: Database-Level0.1-10msDisk + configured buffer poolBuffer pool managementHot rows, query plan cache, materialized views
The most common mistake is skipping straight to L4 without considering L1-L3.

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.

The rule: Optimize the left side of the table first. Each layer to the right adds latency, operational cost, and failure modes.

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_buffers setting 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.

What to do instead:
// ✅ 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:

StrategyP50 LatencyP99 LatencyCache Hit RateMemory UsageOperational Cost
No caching45ms320ms$0
In-process LRU (10K entries, 60s TTL)2ms85ms78%~200MB$0
Redis cache-aside8ms150ms72%4.2GB$45/mo (c6g.large)
Redis + in-process (multi-layer)2ms80ms91%4.4GB$45/mo
CDN (+ in-process)3ms (cache miss: 15ms)120ms94%~200MB + CDN~$20/mo (CDN)
The takeaway: An in-process cache with 10,000 entries handles most of the traffic. Redis adds value for shared state (rate limits, leaderboards) but not for read-heavy query caching. A CDN performs better than Redis for public, cacheable responses.

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:

  1. 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.

  1. 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.

  1. 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.

  1. 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.

  1. 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

Got a project that needs illuminating?

We bring clarity to complex software challenges. Let's talk.

Get In Touch