← Back to BlogArticle

Redis Caching Strategies Every Backend Developer Should Know in 2026

Stop treating Redis like a glorified hashmap. Most apps use SET key value and call it a day — then wonder why they're still hammering the database at scale. This post covers the caching strategies that actually matter, when to use each, and the failure modes nobody talks about.

Cache-Aside (Lazy Loading)

The default pattern. Your app checks Redis first; on a miss, it fetches from the DB and populates the cache.

async function getUser(id) {
  const cached = await redis.get(`user:${id}`);
  if (cached) return JSON.parse(cached);
 
  const user = await db.query('SELECT * FROM users WHERE id = $1', [id]);
  await redis.setex(`user:${id}`, 3600, JSON.stringify(user));
  return user;
}

When to use: Read-heavy workloads where you can tolerate a cold-start miss penalty.

Failure mode: Cache stampede. If 500 requests all miss simultaneously (say, after a deploy flush), they all hit the DB at once. Fix with a mutex lock or probabilistic early expiration.

const ttl = await redis.ttl(`user:${id}`);
const remainingRatio = ttl / BASE_TTL;
if (remainingRatio < Math.random()) {
  refreshCache(id);
}

Write-Through

Write to Redis and the DB simultaneously on every update. Cache is always warm, never stale.

async function updateUser(id, data) {
  await db.query('UPDATE users SET ... WHERE id = $1', [id]);
  await redis.setex(`user:${id}`, 3600, JSON.stringify(data));
}

When to use: When read latency is critical and you can afford slightly slower writes.

Failure mode: Always write to the DB first, then update cache — not the other way around. A Redis write succeeding before a DB failure leaves you with a dirty cache.

Write-Behind (Write-Back)

Writes go to Redis immediately; a background worker flushes to the DB asynchronously. Blazing fast writes.

await redis.lpush('write_queue', JSON.stringify({ id, data }));

When to use: High-write throughput — leaderboards, counters, analytics ingestion.

Failure mode: Redis crash before flush = data loss. Only viable with AOF+RDB persistence or a proper queue like BullMQ/Kafka as the buffer.

Cache Invalidation Strategies

This is where most teams get it wrong.

TTL-based expiry — Simple. Works for data that tolerates staleness. Tune TTL per entity type, not a global 3600 for everything.

Event-driven invalidation — On write, delete or update specific cache keys. Works well with Postgres LISTEN/NOTIFY or a message broker.

Never use KEYS user:* in production — it blocks Redis. Use SCAN:

async function deleteByPattern(pattern) {
  let cursor = '0';
  do {
    const [nextCursor, keys] = await redis.scan(cursor, 'MATCH', pattern, 'COUNT', 100);
    if (keys.length) await redis.del(...keys);
    cursor = nextCursor;
  } while (cursor !== '0');
}

Cache versioning — Bump a version key instead of deleting. Old keys expire via TTL. Stampede-resistant and simple.

const version = await redis.get('user:version');
const cacheKey = `user:${id}:v${version}`;

Use the Right Data Structure

Most devs reach for strings when Redis has better primitives:

  • HASH — Session/object data. Field-level reads, no full deserialize.
  • ZSET — Leaderboards. O(log N) rank queries built-in.
  • INCR + EXPIRE — Rate limiting. Atomic, no race condition.
  • HyperLogLog — Unique counts. ~1% error at 12KB vs millions of entries in a SET.

Sliding window rate limiter with ZSET:

async function isRateLimited(userId, limit = 100, windowSec = 60) {
  const key = `ratelimit:${userId}`;
  const now = Date.now();
  const windowStart = now - windowSec * 1000;
 
  await redis.zremrangebyscore(key, '-inf', windowStart);
  const count = await redis.zcard(key);
  if (count >= limit) return true;
 
  await redis.zadd(key, now, `${now}`);
  await redis.expire(key, windowSec);
  return false;
}

Memory Management

Set a maxmemory and eviction policy that matches your use case — don't leave it as noeviction:

maxmemory 512mb
maxmemory-policy allkeys-lru
  • allkeys-lru — Evicts least recently used. Good general default.
  • allkeys-lfu — Evicts least frequently used. Better for skewed access patterns.
  • volatile-lru — LRU only among keys with TTL set.
  • noeviction — Errors on write when full. Avoid unless you know what you're doing.

Key Naming Convention

Establish a pattern before you have 200 different key formats:

{service}:{entity}:{id}:{field?}
 
user:profile:123
user:session:abc456
product:listing:page:3
ratelimit:api:userId:789

Namespace with colons. Makes SCAN filtering sane and avoids collisions in shared Redis instances.

TL;DR

  • Cache-aside for most read-heavy use cases — add stampede protection
  • Write-through when you need zero read staleness
  • Write-behind for write-heavy workloads — only with durable backing
  • Never use KEYS in production — always SCAN
  • Pick the right data structure for the job
  • Set maxmemory + eviction policy — don't leave it as noeviction
  • Version your cache keys to make invalidation trivial