Siddhant Deval
Siddhant DevalAuthor
Senior Full-Stack Engineer·Sep 20, 2026·20 min read

Cache Invalidation Strategies: Cache-Aside, Write-Through, Write-Back & CDC Pipelines

Cache invalidation is inherently a dual-write problem across uncoordinated storage systems. This article breaks down why Write-Invalidate beats Write-Update, exposes the insidious Replica-Lag Cache Poisoning trap, analyzes Write-Back crash-loss tradeoffs, and derives transactional CDC log-tailing via Debezium and Kafka.

Cache Invalidation Strategies: Cache-Aside, Write-Through, Write-Back & CDC Pipelines

A cache is not an optimization layer you sprinkle over slow queries — it is a volatile, distributed state machine with independent failure modes and eventual consistency tradeoffs. Invalidation is the exact boundary where those tradeoffs turn into production bugs. Phil Karlton famously noted that cache invalidation is one of the two hard things in Computer Science. The reason is mechanical: updating state across a database and an external caching layer constitutes an uncoordinated dual-write across two independent network services. When engineers attempt to keep these systems synchronized with naive application-layer updates, they introduce silent race conditions, stale data overwrites, and the catastrophic "Replica-Lag Cache Poisoning" trap. This article breaks down every major cache write topology, analyzes their failure modes, and derives a resilient Change Data Capture (CDC) invalidation architecture.

Architectural Note

This is Part 2 of the Caching & Distributed Concurrency series. It builds directly on the multi-tier hierarchy and Redis clustering models established in Part 1 — Caching Topologies.


1. The Dual-Write Hazard: Why Write-Invalidate Beats Write-Update

When a backend service updates an entity in the primary database, it must decide what to do with the corresponding cache entry. Developers intuitively choose between two patterns:

  1. Write-Update: Update the database row, then immediately write the new value into the cache (redis.set(key, newValue)).
  2. Write-Invalidate: Update the database row, then delete the key from the cache (redis.del(key)), forcing the next reader to repopulate it lazily.

Under any non-trivial concurrency, Write-Update is fundamentally broken.

TYPESCRIPT
// ❌ Broken pattern: Cache-Aside with Write-Update under concurrent requests
export async function updateUserProfile(userId: string, updates: Partial<User>) {
  // Thread 1 (Setting name = 'Alice') and Thread 2 (Setting name = 'Bob') execute concurrently
  await db.user.update({ where: { id: userId }, data: updates });

  // 💥 RACE CONDITION:
  // 1. Thread 1 updates DB to 'Alice'
  // 2. Thread 2 updates DB to 'Bob'
  // 3. Thread 2 sets Redis to 'Bob'
  // 4. Thread 1 (delayed by GC pause/network jitter) sets Redis to 'Alice'
  // RESULT: Database holds 'Bob', but Cache permanently holds 'Alice'!
  await redis.set(`user:${userId}`, JSON.stringify(updates));
}
TYPESCRIPT
// ✅ Resilient pattern: Cache-Aside with Write-Invalidate (Post-Commit Hook)
export async function updateUserProfileResilient(userId: string, updates: Partial<User>) {
  // 1. Execute DB mutation inside an ACID transaction
  await db.$transaction(async (tx) => {
    await tx.user.update({ where: { id: userId }, data: updates });
    
    // 2. Schedule cache deletion ONLY after the transaction successfully commits
    tx.afterCommit(async () => {
      // Invalidate the cache — next read will safely pull latest committed state from DB
      await redis.del(`user:${userId}`);
    });
  });
}

1.1 The Interleaved Concurrent Write Race

When two concurrent requests attempt to update the same record using Write-Update, their execution order at the database level does not guarantee their arrival order at the Redis level. A slow network packet from an earlier transaction will overwrite a newer write in Redis, permanently de-synchronizing the cache from the database until the key's TTL expires.

By contrast, Write-Invalidate (DEL) is idempotent: deleting an already-deleted key is a harmless no-op. Even if deletions arrive out of order, the cache simply remains empty until a subsequent read fetches the latest committed database state.

1.2 The Post-Commit Deletion Contract

A common footgun with Write-Invalidate is deleting the cache key before committing the database transaction:

Crucial Requirement

Transaction Invariant: In Cache-Aside, never delete a cache key prior to transaction commit. Always register cache deletions as post-commit hooks (afterCommit) to ensure that readers only repopulate the cache from committed transactions.


2. The "Replica-Lag Cache Poisoning" Trap

Even with post-commit cache invalidation, modern high-scale architectures introduce a subtle distributed systems vulnerability: Replica-Lag Cache Poisoning.

Most production backends route write queries to a primary database node and read queries to read-replicas to scale throughput. Replicas synchronize asynchronously via WAL streaming, creating a replication lag window ($\tau_{\text{lag}}$), typically 10–100ms.

TYPESCRIPT
// ❌ Broken pattern: Cache-aside reading from an asynchronous read replica
export async function getUser(userId: string) {
  const cached = await redis.get(`user:${userId}`);
  if (cached) return JSON.parse(cached);

  // 💥 DANGER: dbReplica is lagging by 40ms.
  // If an update just committed on dbPrimary and deleted Redis, this read fetches
  // the pre-update stale record from dbReplica and caches it in Redis for 1 hour!
  const user = await dbReplica.user.findUnique({ where: { id: userId } });
  if (user) {
    await redis.set(`user:${userId}`, JSON.stringify(user), 'EX', 3600);
  }
  return user;
}

// ✅ Resilient pattern: Delayed Double Deletion & Primary-Pinned Re-reads
export async function invalidateWithDelayedDoubleDelete(userId: string, estimatedLagMs = 500) {
  const key = `user:${userId}`;

  // 1. Immediate invalidation post-commit
  await redis.del(key);

  // 2. Schedule a second deletion after the maximum replication lag window has passed
  setTimeout(async () => {
    try {
      await redis.del(key);
    } catch (err) {
      console.error(`Failed secondary delayed cache invalidation for ${key}:`, err);
    }
  }, estimatedLagMs);
}

2.1 Mitigations for Replica Lag Poisoning

  1. Primary-Pinned Re-Reads: Configure your database routing middleware so that if a read occurs immediately following a cache miss for a recently mutated entity, the query routes directly to the primary DB node.
  2. Delayed Double Deletion: Execute an immediate DEL post-commit, and enqueue an asynchronous task (via SQS/BullMQ) to execute a second DEL after $2 \times \tau_{\text{max_lag}}$ (e.g., 500ms). Any stale write populated during the replication lag window is purged by the second deletion.
  3. CDC-Driven Invalidation: Defer cache invalidation entirely until replication log events are committed on all replicas (covered in Section 4).

3. Synchronous vs. Asynchronous Write Topologies

When selecting a write architecture, systems trade write latency, read latency, and durability:

Pattern Write Latency Read Latency Consistency Guarantee Crash Durability Risk
Cache-Aside (Lazy) Low (DB only) High on miss, Low on hit Eventual (Write-Invalidate) None (DB is source of truth)
Read/Write-Through High (DB + Cache sync) Zero miss penalty (warmed) Strong within single thread None (DB updated synchronously)
Write-Back (Write-Behind) Ultra-Low (<1ms in-memory) Ultra-Low (Always in RAM) Eventual (Async batched DB flush) HIGH: Data lost if cache crashes before flush
Comparison matrix of Cache-Aside, Write-Through, and Write-Back write patterns across latency, consistency, and crash failure modes.
Comparison matrix of Cache-Aside, Write-Through, and Write-Back write patterns across latency, consistency, and crash failure modes.

3.1 Write-Back (Write-Behind) Internals & Durability Engineering

Write-Back caching achieves extreme write throughput by acknowledging writes immediately in memory and asynchronously flushing batched updates to the database. It is widely used for analytics counters, gaming leaderboards, and telemetry feeds.

However, Write-Back introduces a severe durability hazard: if the Redis node or worker process terminates abruptly before dirty blocks are flushed to PostgreSQL, committed user writes are permanently destroyed.

TYPESCRIPT
// ✅ Production Write-Back Buffer with Persistent WAL / Stream Protection
import Redis from 'ioredis';

export class WriteBackBuffer {
  private redis: Redis;
  private flushBatchSize = 1000;

  constructor(redisClient: Redis) {
    this.redis = redisClient;
  }

  // 1. Write to Redis Stream (WAL) AND Memory Hash atomically via Pipeline
  async recordMetric(metricId: string, delta: number) {
    const pipeline = this.redis.pipeline();
    
    // In-memory counter for instant reads
    pipeline.hincrby('metrics:live', metricId, delta);
    
    // Append-only Redis Stream as durable Write-Ahead Log (WAL)
    pipeline.xadd('metrics:wal', '*', 'id', metricId, 'delta', delta.toString());
    
    await pipeline.exec();
  }

  // 2. Background consumer flushes batched writes to Postgres with transaction safety
  async flushDirtyMetricsToDatabase() {
    // Read batch of entries from Redis Stream
    const entries = await this.redis.xread('COUNT', this.flushBatchSize, 'STREAMS', 'metrics:wal', '0');
    if (!entries || entries.length === 0) return;

    const [streamName, streamRows] = entries[0];
    const updates = new Map<string, number>();
    const ackIds: string[] = [];

    // Write coalescing: collapse 10,000 stream events into aggregated row sums
    for (const [id, fields] of streamRows) {
      const metricId = fields[1];
      const delta = parseInt(fields[3], 10);
      updates.set(metricId, (updates.get(metricId) || 0) + delta);
      ackIds.push(id);
    }

    // Persist coalesced batch to PostgreSQL inside single transaction
    await db.$transaction(async (tx) => {
      for (const [metricId, totalDelta] of updates.entries()) {
        await tx.$executeRaw`
          INSERT INTO metrics (id, count) VALUES (${metricId}, ${totalDelta})
          ON CONFLICT (id) DO UPDATE SET count = metrics.count + ${totalDelta}
        `;
      }
    });

    // Acknowledge and trim stream only after DB commit succeeds
    await this.redis.xdel('metrics:wal', ...ackIds);
  }
}

4. Change Data Capture (CDC): Transactional Invalidation via WAL

Application-level cache invalidation (tx.afterCommit(() => redis.del(key))) works well for simple monolithic services. However, in distributed architectures with multiple microservices, background jobs, and direct database migrations, application code cannot be relied upon to invalidate every cache key reliably.

The gold standard for decoupled, resilient cache invalidation is Change Data Capture (CDC) via database transaction log tailing.

Comparison of dual-write race condition versus CDC transaction log-tailing pipeline.
Comparison of dual-write race condition versus CDC transaction log-tailing pipeline.

4.1 How CDC Solves the Dual-Write Problem

  1. Zero Dual Writes: The application writes only to PostgreSQL. It contains zero Redis invalidation logic.
  2. Deterministic Commit Ordering: Postgres writes every change to its Write-Ahead Log (WAL) in exact sequential order.
  3. Guaranteed Delivery: Debezium captures row-level changes via Postgres logical decoding (pgoutput) and publishes them to a Kafka topic.
  4. Decoupled Eviction: An independent invalidation consumer reads the Kafka stream and issues redis.del(key) or redis.unlink(key). If Redis is temporarily down, Kafka retains the offset and replays invalidations upon reconnection.

4.2 Invalidation Blast Radius: SCAN vs KEYS *

When invalidating collections or wildcards (e.g., purging all user sessions under session:user_100:*), many developers instinctively run redis.keys('session:user_100:*').

Performance / Safety Warning

Production Critical: KEYS * runs in $O(N)$ time where $N$ is the total number of keys in the Redis dataset. On a database with 10 million keys, KEYS * blocks the single-threaded Redis event loop for 200–800ms, causing cascading API timeouts across your entire platform.

TYPESCRIPT
// ❌ Fatal: Blocking KEYS command
const keys = await redis.keys(`tenant:${tenantId}:*`);
if (keys.length > 0) await redis.del(...keys);

// ✅ Resilient Pattern 1: Non-blocking SCAN iterator
export async function purgeNamespaceWithScan(pattern: string) {
  let cursor = '0';
  do {
    // Scan in chunks of 500 keys without blocking event loop
    const [nextCursor, keys] = await redis.scan(cursor, 'MATCH', pattern, 'COUNT', 500);
    cursor = nextCursor;
    if (keys.length > 0) {
      // UNLINK is asynchronous: reclaims memory in a background thread
      await redis.unlink(...keys);
    }
  } while (cursor !== '0');
}

// ✅ Resilient Pattern 2: Explicit Reverse-Index Sets (Zero Scanning Overhead)
export async function invalidateTenantExplicit(tenantId: string) {
  const indexKey = `tenant:${tenantId}:keys_index`;
  
  // Fetch all registered entity keys in O(1)
  const keys = await redis.smembers(indexKey);
  if (keys.length > 0) {
    const pipeline = redis.pipeline();
    pipeline.unlink(...keys);
    pipeline.unlink(indexKey);
    await pipeline.exec();
  }
}

Summary

Strategy Mechanical Rule
Write-Invalidate Never update cached entities on write (Cache.set()); always invalidate (Cache.del()) to prevent concurrent write interleaving from poisoning the cache.
Post-Commit Invalidation Invalidate cache keys only after database transaction commit (afterCommit); invalidating before commit allows concurrent reads to cache pre-commit state.
Replica-Lag Poisoning When reading from asynchronous replicas, apply Delayed Double Deletion or pin cache-miss re-reads to the primary database to prevent caching stale replica data.
Write-Back Durability Write-Back delivers extreme write throughput by buffering in RAM, but must be paired with an append-only Redis Stream WAL to prevent permanent data loss during crashes.
CDC Pipeline Change Data Capture (Debezium + Kafka) completely eliminates application dual writes by deriving cache invalidations directly from the database WAL.
Zero KEYS * Policy Never execute KEYS in production; use non-blocking cursor-based SCAN with UNLINK, or maintain explicit reverse-index Sets for $O(1)$ multi-key invalidations.

What's Next

Now that we have mastered cache write paths and invalidation architectures, Part 3: High-Concurrency Cache Hazards investigates what happens when systems experience extreme traffic spikes: thundering herds, the mathematical derivation of the XFetch algorithm, Bloom filters, and memory eviction internals.

Research & Synthesis Note

This article was developed with AI-assisted deep search, specification cross-referencing, and technical research synthesis.

#Cache Invalidation#CDC#Kafka#Redis#Distributed Systems
Siddhant Deval

Written by Siddhant Deval

Senior Full-Stack Engineer building high-scale architectures, browser performance engineering systems, and SaaS platforms.