Cloud and platform

Distributed Caching Patterns: Strategies for Scale and Consistency

Caching is the difference between a system that scales and one that collapses under load. Learn write-through, write-behind, read-through, and cache invalidation patterns for distributed systems.

3/11/20269 min readCloud
Distributed Caching Patterns: Strategies for Scale and Consistency

Executive summary

Caching is the difference between a system that scales and one that collapses under load. Learn write-through, write-behind, read-through, and cache invalidation patterns for distributed systems.

Last updated: 3/11/2026

The caching problem

At scale, every database query becomes a bottleneck. The story is familiar: application launches, traffic grows, database connection pools exhaust, response times spike, and the system collapses under load. Caching solves this by storing frequently accessed data in memory, reducing database load dramatically.

The challenge is implementing caching correctly. Poorly designed caches introduce stale data, inconsistent state, and complexity that negates performance gains. The difference between a system that scales and one that introduces bugs lies in understanding cache patterns: when to write, what to invalidate, and how to handle misses.

Effective caching in distributed systems requires choosing the right pattern for your use case: cache-aside for read-heavy workloads, write-through for strong consistency, write-behind for write-heavy throughput, and read-through for simplified application logic.

Cache-aside pattern

The cache-aside pattern (also called lazy loading) checks the cache first, then falls back to the database on miss, populating the cache for subsequent requests.

When to use cache-aside

  • Read-heavy workloads with frequent repeated access to same data
  • Data that changes infrequently
  • When you want cache populated only on demand
  • When database load reduction is the primary goal

Implementation

typescriptclass CacheAsideService {
  constructor(
    private cache: RedisClient,
    private database: Database
  ) {}

  async getUser(userId: number): Promise<User> {
    // Step 1: Check cache
    const cacheKey = `user:${userId}`;
    const cached = await this.cache.get(cacheKey);

    if (cached) {
      this.metrics.record('cache_hit', { key: cacheKey });
      return JSON.parse(cached);
    }

    // Step 2: Cache miss - fetch from database
    this.metrics.record('cache_miss', { key: cacheKey });
    const user = await this.database.users.findUnique({
      where: { id: userId }
    });

    if (!user) {
      throw new NotFoundError(`User ${userId} not found`);
    }

    // Step 3: Populate cache for future requests
    await this.cache.setex(cacheKey, 3600, JSON.stringify(user));

    return user;
  }

  async updateUser(userId: number, data: Partial<UserData>): Promise<User> {
    // Step 1: Update database
    const user = await this.database.users.update({
      where: { id: userId },
      data
    });

    // Step 2: Invalidate cache
    const cacheKey = `user:${userId}`;
    await this.cache.del(cacheKey);
    this.metrics.record('cache_invalidation', { key: cacheKey });

    // Next read will repopulate cache with fresh data
    return user;
  }
}

Advantages and trade-offs

AdvantagesTrade-offs
Cache populated only on demandFirst request suffers from cache miss latency
Simple implementationStale data possible between invalidation and repopulation
Works well for read-heavy workloadsThundering herd if cache expires and multiple requests hit simultaneously

Mitigating thundering herd

typescriptasync getUserWithLock(userId: number): Promise<User> {
  const cacheKey = `user:${userId}`;
  const lockKey = `lock:${cacheKey}`;

  // Check cache first
  const cached = await this.cache.get(cacheKey);
  if (cached) {
    return JSON.parse(cached);
  }

  // Acquire lock to prevent thundering herd
  const lockAcquired = await this.cache.set(lockKey, '1', {
    NX: true,
    EX: 10 // 10 second lock timeout
  });

  if (lockAcquired) {
    try {
      // We hold the lock - fetch from database
      const user = await this.database.users.findUnique({
        where: { id: userId }
      });

      if (user) {
        await this.cache.setex(cacheKey, 3600, JSON.stringify(user));
      }

      return user;
    } finally {
      // Release lock
      await this.cache.del(lockKey);
    }
  } else {
    // Wait for lock holder and retry
    await sleep(100);
    return this.getUser(userId);
  }
}

Write-through pattern

The write-through pattern writes to both cache and database synchronously. The cache is always consistent with the database.

When to use write-through

  • When you need strong consistency between cache and database
  • When read latency is more important than write latency
  • When cache misses are expensive and should be minimized
  • When you can afford slightly slower write operations

Implementation

typescriptclass WriteThroughCacheService {
  async createUser(data: UserData): Promise<User> {
    // Write to database first
    const user = await this.database.users.create({
      data
    });

    // Immediately write to cache
    const cacheKey = `user:${user.id}`;
    await this.cache.setex(cacheKey, 3600, JSON.stringify(user));
    this.metrics.record('write_through', { key: cacheKey });

    return user;
  }

  async updateUser(userId: number, data: Partial<UserData>): Promise<User> {
    // Update in database
    const user = await this.database.users.update({
      where: { id: userId },
      data
    });

    // Update in cache
    const cacheKey = `user:${userId}`;
    await this.cache.setex(cacheKey, 3600, JSON.stringify(user));
    this.metrics.record('write_through', { key: cacheKey });

    return user;
  }

  async getUser(userId: number): Promise<User> {
    const cacheKey = `user:${userId}`;
    const cached = await this.cache.get(cacheKey);

    if (cached) {
      this.metrics.record('cache_hit', { key: cacheKey });
      return JSON.parse(cached);
    }

    // Cache miss - shouldn't happen with write-through
    // but handle gracefully for cache failures
    const user = await this.database.users.findUnique({
      where: { id: userId }
    });

    if (user) {
      await this.cache.setex(cacheKey, 3600, JSON.stringify(user));
    }

    return user;
  }
}

Advantages and trade-offs

AdvantagesTrade-offs
Strong consistency between cache and databaseSlower write operations (two writes)
Cache always contains latest dataHigher latency on writes
No thundering herd on cache missUnnecessary writes for data that won't be read

Write-behind pattern

The write-behind pattern (also called write-back) writes to cache first and asynchronously persists to the database. This maximizes write throughput.

When to use write-behind

  • Write-heavy workloads requiring high throughput
  • When eventual consistency is acceptable
  • When you can tolerate potential data loss on cache failure
  • For high-volume writes that don't need immediate persistence

Implementation

typescriptclass WriteBehindCacheService {
  private writeQueue: WriteQueue;

  async updateUser(userId: number, data: Partial<UserData>): Promise<User> {
    const cacheKey = `user:${userId}`;

    // Update in cache immediately (fast write)
    const current = await this.cache.get(cacheKey);
    const user = current ? JSON.parse(current) : {};
    const updated = { ...user, ...data };

    await this.cache.setex(cacheKey, 3600, JSON.stringify(updated));

    // Queue for async database write
    await this.writeQueue.push({
      type: 'UPDATE_USER',
      userId,
      data,
      timestamp: Date.now()
    });

    return updated;
  }

  async startWriterWorker(): Promise<void> {
    setInterval(async () => {
      const batch = await this.writeQueue.getBatch(100); // Batch up to 100 items

      for (const item of batch) {
        try {
          await this.processWriteItem(item);
        } catch (error) {
          this.logger.error('Write-behind failed', { error, item });
          // Retry or move to dead letter queue
        }
      }
    }, 1000); // Process every second
  }

  private async processWriteItem(item: WriteQueueItem): Promise<void> {
    switch (item.type) {
      case 'UPDATE_USER':
        await this.database.users.update({
          where: { id: item.userId },
          data: item.data
        });
        break;
      // Handle other write types
    }
  }
}

Advantages and trade-offs

AdvantagesTrade-offs
Maximum write throughputData loss possible if cache fails before persisting
Fast response time for writesComplex recovery and consistency handling
Batching reduces database loadEventual consistency
Reduces write amplificationDifficult to implement correctly

Read-through pattern

The read-through pattern abstracts caching logic behind a simple interface. The cache handles misses automatically by fetching from the database.

When to use read-through

  • When you want to simplify application code
  • When caching logic should be centralized
  • When you have many similar cache lookups
  • When you want consistent caching behavior across codebase

Implementation

typescriptclass ReadThroughCache<T> {
  constructor(
    private cache: RedisClient,
    private loader: (key: string) => Promise<T>,
    private ttl: number = 3600
  ) {}

  async get(key: string): Promise<T> {
    const cached = await this.cache.get(key);

    if (cached) {
      this.metrics.record('cache_hit', { key });
      return JSON.parse(cached);
    }

    // Cache miss - use loader
    this.metrics.record('cache_miss', { key });
    const value = await this.loader(key);

    // Store in cache
    await this.cache.setex(key, this.ttl, JSON.stringify(value));

    return value;
  }

  async invalidate(key: string): Promise<void> {
    await this.cache.del(key);
    this.metrics.record('cache_invalidation', { key });
  }
}

// Usage
class UserService {
  private userCache: ReadThroughCache<User>;

  constructor(cache: RedisClient, database: Database) {
    this.userCache = new ReadThroughCache(
      cache,
      async (key) => {
        const userId = parseInt(key.split(':')[1]);
        return database.users.findUnique({ where: { id: userId } });
      },
      3600 // 1 hour TTL
    );
  }

  async getUser(userId: number): Promise<User> {
    return this.userCache.get(`user:${userId}`);
  }

  async updateUser(userId: number, data: Partial<UserData>): Promise<User> {
    const user = await this.database.users.update({
      where: { id: userId },
      data
    });

    await this.userCache.invalidate(`user:${userId}`);
    return user;
  }
}

Cache invalidation strategies

The hardest part of caching is knowing when to invalidate cached data.

Time-based expiration

typescript// Set TTL on cache keys
await this.cache.setex(`user:${userId}`, 3600, JSON.stringify(user)); // 1 hour

// Configure different TTLs based on data volatility
const ttlConfig = {
  static: 86400,      // 24 hours
  frequent: 3600,      // 1 hour
  volatile: 300,        // 5 minutes
  session: 1800        // 30 minutes
};

Event-based invalidation

typescriptclass CacheInvalidationService {
  async invalidateUserRelated(userId: number): Promise<void> {
    // Invalidate direct user cache
    await this.cache.del(`user:${userId}`);

    // Invalidate related caches
    const userPosts = await this.database.posts.findMany({
      where: { userId },
      select: { id: true }
    });

    for (const post of userPosts) {
      await this.cache.del(`post:${post.id}`);
    }

    // Invalidate user's feed cache
    await this.cache.del(`feed:${userId}`);

    this.metrics.record('cascade_invalidation', {
      userId,
      itemsInvalidated: userPosts.length + 2
    });
  }
}

Tag-based invalidation

typescriptclass TaggedCache {
  async set(key: string, value: any, tags: string[], ttl: number): Promise<void> {
    await this.cache.setex(key, ttl, JSON.stringify(value));

    // Track tags for this key
    for (const tag of tags) {
      await this.cache.sadd(`tags:${tag}`, key);
    }
  }

  async invalidateTag(tag: string): Promise<void> {
    // Get all keys with this tag
    const keys = await this.cache.smembers(`tags:${tag}`);

    // Delete all tagged keys
    if (keys.length > 0) {
      await this.cache.del(...keys);
    }

    // Clean up tag set
    await this.cache.del(`tags:${tag}`);
  }
}

// Usage
await taggedCache.set('user:123', userData, ['user', 'frequent'], 3600);
await taggedCache.set('post:456', postData, ['post', 'volatile'], 300);

// Invalidate all user-related caches
await taggedCache.invalidateTag('user');

Distributed cache consistency

In distributed systems with multiple cache nodes, maintaining consistency becomes challenging.

Redis Cluster

typescriptclass RedisClusterCache {
  private client: RedisCluster;

  async get(key: string): Promise<string | null> {
    return this.client.get(key);
  }

  async set(key: string, value: string, options: { ttl?: number }): Promise<void> {
    if (options.ttl) {
      await this.client.setex(key, options.ttl, value);
    } else {
      await this.client.set(key, value);
    }
  }
}

Cache warming

typescriptclass CacheWarmupService {
  async warmUserCache(userIds: number[]): Promise<void> {
    const batchSize = 100;

    for (let i = 0; i < userIds.length; i += batchSize) {
      const batch = userIds.slice(i, i + batchSize);

      await Promise.all(
        batch.map(async (userId) => {
          const user = await this.database.users.findUnique({
            where: { id: userId }
          });

          if (user) {
            await this.cache.setex(
              `user:${userId}`,
              3600,
              JSON.stringify(user)
            );
          }
        })
      );
    }
  }
}

Common caching anti-patterns

Anti-pattern 1: Caching everything

Problem: Caching all data regardless of access pattern.

Consequences: Cache memory exhausted, low hit rates, degraded performance.

Solution: Cache only frequently accessed data with significant retrieval cost.

Anti-pattern 2: No cache invalidation strategy

Problem: Setting very long TTL or never invalidating cache.

Consequences: Stale data propagates through system, inconsistent state.

Solution: Implement time-based expiration plus event-based invalidation for critical data.

Anti-pattern 3: N+1 query pattern with cache

Problem: Caching individual items in a loop.

typescript// BAD
for (const userId of userIds) {
  const user = await getUser(userId); // Each call checks cache individually
  users.push(user);
}

// GOOD
const keys = userIds.map(id => `user:${id}`);
const cached = await this.cache.mget(...keys);
// Then fetch only missing users from database

Anti-pattern 4: Ignoring cache failures

Problem: Not handling cache failures gracefully.

Consequences: Application crashes when cache is unavailable.

Solution: Implement fallback to database when cache fails.

typescriptasync getWithFallback(key: string): Promise<any> {
  try {
    const cached = await this.cache.get(key);
    if (cached) return JSON.parse(cached);
  } catch (error) {
    this.logger.warn('Cache unavailable, falling back to database', { error });
  }

  return this.database.query(...);
}

Cache technology selection

TechnologyBest forTrade-offs
RedisComplex data structures, persistenceSingle-threaded, memory-intensive
MemcachedSimple key-value, high throughputNo persistence, limited data types
HazelcastDistributed computing, in-memory data gridComplex setup, heavier footprint
IgniteSQL-on-memory, distributed transactionsSteeper learning curve

Monitoring and metrics

Track cache effectiveness to optimize your strategy.

typescriptinterface CacheMetrics {
  hitRate: number;
  missRate: number;
  avgHitLatency: number;
  avgMissLatency: number;
  evictionCount: number;
  memoryUsage: number;
}

async getCacheMetrics(): Promise<CacheMetrics> {
  const hits = await this.metrics.getCounter('cache_hit');
  const misses = await this.metrics.getCounter('cache_miss');
  const total = hits + misses;

  return {
    hitRate: total > 0 ? hits / total : 0,
    missRate: total > 0 ? misses / total : 0,
    avgHitLatency: await this.metrics.getAverage('cache_hit_latency'),
    avgMissLatency: await this.metrics.getAverage('cache_miss_latency'),
    evictionCount: await this.cache.dbsize(),
    memoryUsage: await this.cache.info('memory').then(info => info.used_memory)
  };
}

Conclusion

Distributed caching patterns provide a toolkit for building scalable systems. Cache-aside suits read-heavy workloads, write-through ensures consistency, write-behind maximizes throughput, and read-through simplifies application logic.

The key is matching the pattern to your use case. Don't cache everything—focus on frequently accessed, expensive-to-retrieve data. Implement proper invalidation to prevent stale data. Monitor cache metrics to tune TTL and identify patterns.

Start with cache-aside for read-heavy paths. Add write-through where consistency matters. Consider write-behind for write-heavy workloads accepting eventual consistency. Always handle cache failures gracefully by falling back to the database.


Your system needs a caching strategy that scales with growth? Talk to Imperialis engineering specialists about distributed caching implementation, pattern selection, and performance optimization for your production architecture.

Sources

Related reading