Redis Caching Patterns for Web Applications

Master Redis caching patterns for Node.js and NestJS: cache-aside, write-through, cache stampede prevention, TTL strategies, invalidation, and production monitoring.

E
ECOSIRE Research and Development Team
|March 19, 202610 min read2.1k Words|

Part of our Performance & Scalability series

Read the complete guide

Redis Caching Patterns for Web Applications

Database queries are the most common performance bottleneck in web applications. A poorly indexed query that takes 200ms, called on every page load for 1,000 concurrent users, generates 200 seconds of database CPU per second — a death spiral. Redis is the antidote: a sub-millisecond in-memory store that absorbs the read load that would otherwise hit your database repeatedly for identical data.

But caching is not just "put things in Redis." The wrong pattern causes stale data bugs, thundering herd disasters, or unbounded memory growth. This guide covers the four canonical caching patterns, cache invalidation strategies, stampede prevention, and production NestJS implementation so you cache correctly from the start.

Key Takeaways

  • Cache-aside (lazy loading) is the safest default — only cache what is actually requested
  • Write-through keeps cache and database in sync but adds write latency — use for frequently-read, infrequently-written data
  • Cache stampede (thundering herd) destroys databases when a popular key expires — use probabilistic early expiration or mutex locks
  • TTL must be set on every key — unbounded Redis growth is an incident waiting to happen
  • Cache invalidation is hard: prefer short TTLs and event-driven invalidation over trying to invalidate perfectly
  • Cache keys must include all query dimensions: contacts:{orgId}:{page}:{limit}:{search}:{sortField}:{sortDir}
  • Use Redis namespaces and key patterns to enable bulk invalidation
  • Monitor cache hit rate; below 80% means your TTLs are too short or your key structure is wrong

Redis Setup in NestJS

pnpm add ioredis @nestjs-modules/ioredis
// src/modules/cache/cache.service.ts
import { Injectable, Logger } from '@nestjs/common';
import { InjectRedis } from '@nestjs-modules/ioredis';
import Redis from 'ioredis';

@Injectable()
export class CacheService {
  private readonly logger = new Logger(CacheService.name);

  constructor(@InjectRedis() private readonly redis: Redis) {}

  async get<T>(key: string): Promise<T | null> {
    const value = await this.redis.get(key);
    if (!value) return null;
    return JSON.parse(value) as T;
  }

  async set<T>(key: string, value: T, ttlSeconds = 300): Promise<void> {
    await this.redis.set(key, JSON.stringify(value), 'EX', ttlSeconds);
  }

  async del(key: string): Promise<void> {
    await this.redis.del(key);
  }

  // Use SCAN instead of KEYS to avoid blocking Redis on large key spaces
  async invalidatePattern(pattern: string): Promise<void> {
    let cursor = '0';
    do {
      const [nextCursor, keys] = await this.redis.scan(
        cursor,
        'MATCH', pattern,
        'COUNT', 100
      );
      cursor = nextCursor;
      if (keys.length > 0) {
        await this.redis.del(...keys);
      }
    } while (cursor !== '0');
  }

  async remember<T>(
    key: string,
    ttlSeconds: number,
    factory: () => Promise<T>
  ): Promise<T> {
    const cached = await this.get<T>(key);
    if (cached !== null) return cached;

    const value = await factory();
    await this.set(key, value, ttlSeconds);
    return value;
  }
}
// src/modules/cache/cache.module.ts
import { Global, Module } from '@nestjs/common';
import { RedisModule } from '@nestjs-modules/ioredis';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { CacheService } from './cache.service';

@Global()
@Module({
  imports: [
    RedisModule.forRootAsync({
      imports: [ConfigModule],
      inject: [ConfigService],
      useFactory: (config: ConfigService) => ({
        type: 'single',
        options: {
          host: config.get('REDIS_HOST', 'localhost'),
          port: config.get<number>('REDIS_PORT', 6379),
          password: config.get('REDIS_PASSWORD'),
          db: config.get<number>('REDIS_DB', 0),
          keyPrefix: 'ecosire:',
        },
      }),
    }),
  ],
  providers: [CacheService],
  exports: [CacheService],
})
export class AppCacheModule {}

Pattern 1: Cache-Aside (Lazy Loading)

The most common pattern. Read from cache; on miss, read from database and populate cache.

// contacts.service.ts
async findAll(orgId: string, page: number, limit: number, search?: string) {
  const cacheKey = `contacts:${orgId}:${page}:${limit}:${search ?? 'all'}`;

  return this.cache.remember(cacheKey, 300, async () => {
    const results = await this.db.query.contacts.findMany({
      where: and(
        eq(contacts.organizationId, orgId),
        search
          ? or(
              ilike(contacts.name, `%${search}%`),
              ilike(contacts.email, `%${search}%`)
            )
          : undefined
      ),
      limit,
      offset: (page - 1) * limit,
      orderBy: desc(contacts.createdAt),
    });
    return results;
  });
}

Pros: Simple; cache only contains what is actually requested. Cons: First request is always slow (cache miss); data can be stale until TTL expires.

When to invalidate: After create/update/delete, delete all keys matching contacts:{orgId}:*.


Pattern 2: Write-Through

Update cache simultaneously with the database on every write. Trades write latency for read consistency.

async update(orgId: string, contactId: string, dto: UpdateContactDto) {
  // 1. Update the database
  const [updated] = await this.db
    .update(contacts)
    .set({ ...dto, updatedAt: new Date() })
    .where(
      and(
        eq(contacts.id, contactId),
        eq(contacts.organizationId, orgId)
      )
    )
    .returning();

  // 2. Update the individual contact cache immediately (write-through)
  const singleKey = `contact:${orgId}:${contactId}`;
  await this.cache.set(singleKey, updated, 3600);

  // 3. Invalidate list caches (they now contain stale order/counts)
  await this.cache.invalidatePattern(`contacts:${orgId}:*`);

  return updated;
}

When to use: Frequently-read, infrequently-written records (user profiles, settings, product catalog).


Pattern 3: Cache Stampede Prevention

When a popular cache key expires, hundreds of concurrent requests all miss the cache simultaneously and all hit the database — the "thundering herd." The database collapses under the sudden load.

Mutex Lock Pattern

Only one request rebuilds the cache; others wait and retry:

// cache.service.ts — mutex-protected cache fetch
async getWithLock<T>(
  key: string,
  ttlSeconds: number,
  factory: () => Promise<T>
): Promise<T> {
  // Try cache first
  const cached = await this.get<T>(key);
  if (cached !== null) return cached;

  const lockKey = `lock:${key}`;
  const lockOwner = `${process.pid}-${Date.now()}`;

  // Try to acquire lock: NX = only set if not exists, EX = expire in 30s
  const acquired = await this.redis.set(lockKey, lockOwner, 'NX', 'EX', 30);

  if (acquired === 'OK') {
    try {
      const value = await factory();
      await this.set(key, value, ttlSeconds);
      return value;
    } finally {
      // Atomic check-and-delete: only release if we still own the lock
      // Uses Lua defined with defineCommand for atomicity
      const currentOwner = await this.redis.get(lockKey);
      if (currentOwner === lockOwner) {
        await this.redis.del(lockKey);
      }
    }
  } else {
    // Another process is rebuilding — wait 100ms and retry
    await new Promise<void>((resolve) => {
      const timer = setTimeout(resolve, 100);
      // Store timer reference so Node.js does not block on it
      timer.unref?.();
    });
    return this.getWithLock(key, ttlSeconds, factory);
  }
}

Probabilistic Early Expiration (XFetch)

Start rebuilding the cache probabilistically before it expires, spreading recomputation load:

async getWithEarlyExpiration<T>(
  key: string,
  ttlSeconds: number,
  factory: () => Promise<T>,
  beta = 1 // Higher = more aggressive early recomputation
): Promise<T> {
  const metaKey = `${key}:meta`;
  const meta = await this.get<{ createdAt: number; ttl: number }>(metaKey);
  const value = await this.get<T>(key);

  if (value !== null && meta) {
    const elapsed = Date.now() / 1000 - meta.createdAt;
    const remaining = meta.ttl - elapsed;

    // XFetch: trigger early recomputation probabilistically
    const shouldRecompute = -Math.log(Math.random()) * beta > remaining;
    if (!shouldRecompute) return value;
  }

  const newValue = await factory();
  const now = Date.now() / 1000;
  await this.set(key, newValue, ttlSeconds);
  await this.set(metaKey, { createdAt: now, ttl: ttlSeconds }, ttlSeconds + 60);

  return newValue;
}

Pattern 4: Tag-Based Cache Invalidation

Group cache keys by logical tags and invalidate an entire tag at once:

// Tag-based invalidation using Redis sets
async setWithTags(
  key: string,
  value: unknown,
  ttlSeconds: number,
  tags: string[]
): Promise<void> {
  await this.set(key, value, ttlSeconds);

  // Add key to each tag's member set using a pipeline
  const pipeline = this.redis.pipeline();
  for (const tag of tags) {
    pipeline.sadd(`tag:${tag}`, key);
    pipeline.expire(`tag:${tag}`, ttlSeconds + 60);
  }
  await pipeline.exec();
}

async invalidateByTag(tag: string): Promise<void> {
  const keys = await this.redis.smembers(`tag:${tag}`);
  if (keys.length > 0) {
    const pipeline = this.redis.pipeline();
    for (const cacheKey of keys) {
      pipeline.del(cacheKey);
    }
    pipeline.del(`tag:${tag}`);
    await pipeline.exec();
  }
}

// Usage
await this.setWithTags(
  `contacts:${orgId}:page:1`,
  contacts,
  300,
  [`org:${orgId}`, 'contacts']
);

// After any contact mutation in org_123:
await this.invalidateByTag(`org:${orgId}`);

Session and Auth Caching

Redis is ideal for auth session storage — eliminate database lookups on every request:

// Cache user session data (role, permissions, orgId) for JWT validation
async cacheUserSession(userId: string, session: UserSession): Promise<void> {
  const key = `session:${userId}`;
  await this.redis.hset(key,
    'id',             session.id,
    'email',          session.email,
    'role',           session.role,
    'organizationId', session.organizationId,
    'tokenVersion',   String(session.tokenVersion)
  );
  await this.redis.expire(key, 900); // 15 minutes — matches access token TTL
}

async getUserSession(userId: string): Promise<UserSession | null> {
  const data = await this.redis.hgetall(`session:${userId}`);
  if (!data || Object.keys(data).length === 0) return null;
  return {
    ...data,
    tokenVersion: parseInt(data.tokenVersion, 10),
  } as UserSession;
}

async invalidateUserSession(userId: string): Promise<void> {
  await this.redis.del(`session:${userId}`);
}

TTL Strategy by Data Type

Data TypeRecommended TTLInvalidation Strategy
User profile15 minutesOn profile update
Organization settings1 hourOn settings change
Product catalog24 hoursOn product create/update
Blog post list10 minutesOn post publish
API rate limit counterPer window (60s)Automatic expiry
Auth session15 minutesOn logout or token revoke
Search results5 minutesTTL only
Aggregated metrics1 minuteTTL only
One-time codes (auth)60 secondsAfter use or expiry
Paginated list query5 minutesOn any mutation in org

Monitoring Cache Performance

// Add hit/miss tracking to CacheService
async get<T>(key: string): Promise<T | null> {
  const value = await this.redis.get(key);
  if (value) {
    await this.redis.incr('metrics:cache_hits');
    return JSON.parse(value) as T;
  }
  await this.redis.incr('metrics:cache_misses');
  return null;
}

// Report hit rate every 5 minutes
@Cron('*/5 * * * *')
async reportCacheMetrics(): Promise<void> {
  const [hits, misses] = await this.redis.mget(
    'metrics:cache_hits',
    'metrics:cache_misses'
  );
  const h = parseInt(hits ?? '0', 10);
  const m = parseInt(misses ?? '0', 10);
  const total = h + m;
  const hitRate = total === 0 ? 1 : h / total;

  if (hitRate < 0.8) {
    this.logger.warn(`Low cache hit rate: ${(hitRate * 100).toFixed(1)}%`);
  }

  // Reset counters
  await this.redis.set('metrics:cache_hits', '0');
  await this.redis.set('metrics:cache_misses', '0');
}

Key Redis metrics to monitor via redis-cli INFO stats:

  • keyspace_hits / keyspace_misses — global hit rate (target over 80%)
  • used_memory — watch for unbounded growth
  • evicted_keys — keys evicted by maxmemory policy (should be near 0 for cache-aside)
  • connected_clients — connection pool utilization
  • instantaneous_ops_per_sec — current throughput

Frequently Asked Questions

What maxmemory policy should I use for a cache?

Use allkeys-lru (evict least recently used keys from all keys) or volatile-lru (evict LRU keys that have a TTL set). For a pure cache workload, allkeys-lru is standard — Redis automatically evicts cold keys when memory is full. Never use noeviction for cache — it returns errors when memory is full instead of evicting old data.

How do I avoid storing sensitive data in Redis?

Never cache raw passwords, private keys, payment card data, or SSNs. For auth sessions, cache minimal data: userId, role, organizationId, tokenVersion — not full JWT payloads or API credentials. Enable Redis AUTH and TLS in production. If your Redis instance is compromised, only metadata is exposed when you follow this discipline.

What is the difference between SCAN and KEYS for pattern deletion?

KEYS pattern is a blocking O(n) operation that pauses all Redis commands while scanning — it can cause seconds of downtime on large key spaces. SCAN is non-blocking, iterating in small chunks with a cursor. Always use SCAN for production pattern deletion. The tradeoff is that SCAN may not return all keys if they are added or deleted during the scan — acceptable for cache invalidation.

Should I use a separate Redis instance for caching vs rate limiting vs sessions?

For most applications, one Redis instance with different key prefixes is fine. Separate instances make sense when: the cache needs a different maxmemory-policy than the rate limit store (cache: allkeys-lru, rate limits: noeviction), or when you need independent scaling and failover. At scale, use Redis Cluster with separate clusters per workload type.

How do I handle cache invalidation for paginated list queries?

Paginated list caches are tricky — adding a contact on page 1 shifts all contacts on pages 2+. The pragmatic solution: use short TTLs (2-5 minutes) and invalidate all pages for the organization on any write using pattern invalidation (contacts:{orgId}:*). For large organizations with heavy write volumes, skip caching pagination entirely and rely on database-level optimization (proper indexes, covering indexes) instead.

How do I test cache behavior in Vitest?

Mock the CacheService in unit tests with vi.fn() returning appropriate values for hit/miss scenarios. For integration tests, use a real Redis instance (Docker) and use redis.flushdb() in beforeEach to start clean. Test both the cache-hit path (no database call) and cache-miss path (database called + cache populated) explicitly.


Next Steps

Redis caching is one of the highest-ROI performance investments in a web application. The difference between a 200ms database query and a 2ms Redis hit is 100x — at scale, that translates directly into infrastructure cost savings and user experience improvement.

ECOSIRE implements Redis caching with cache-aside, write-through invalidation, stampede prevention, and cache hit rate monitoring on every NestJS project. Explore our backend engineering services to learn how we design performant, scalable APIs.

E

Written by

ECOSIRE Research and Development Team

Building enterprise-grade digital products at ECOSIRE. Sharing insights on Odoo integrations, e-commerce automation, and AI-powered business solutions.

Chat on WhatsApp