Stop Invalidating Your Cache from Application Code
Dual-write cache invalidation — deleting the cache key right after a database write — is broken in at least three ways. Here's the race condition hiding in your middleware layer, and how a CDC-based architecture fixes it without coordinating two systems from application code.

The Bug You've Shipped a Dozen Times
await db.query(`UPDATE users SET name = $1 WHERE id = $2`, [name, userId]);
await redis.del(`user:${userId}`);This is dual-write cache invalidation. Two separate I/O operations with no atomicity between them. That gap is where your bugs live.
**Failure mode 1: the delete fails silently.** Network blip, Redis timeout, process crash between the two lines — the DB is updated, cache is not cleared. Stale data serves until TTL expires, or forever if you forgot to set one.
**Failure mode 2: the race condition.** Two concurrent readers both miss the cache at the same moment. They both query the DB. One finishes first and populates the cache. Meanwhile, a writer updates the DB and deletes the cache key — but the second reader's DB read already completed with the old value and it's about to write it back into cache right after the delete. You've just re-populated stale data immediately after invalidating it.
**Failure mode 3: rollback doesn't undo the delete.** You delete the cache key inside a transaction that later rolls back. The DB reverted; the cache didn't. The next read is a cold miss that refills correctly, but anything reading between the rollback and the refill sees a gap — and if you're using cache presence as a lock or sentinel, you've now lost that signal entirely.
The Race, Visualized
Step 5 lands after step 4. The cache now holds v1 indefinitely. This won't show up in unit tests, rarely surfaces in staging, and will eventually page someone at 2am because a permissions check is reading a cached role that no longer exists.
The deeper issue: the application is trying to reason about two systems simultaneously from outside both of them. There's no atomic view of cache state and DB state together. You're just hoping the timing works out.
A Better Architecture: CDC-Based Invalidation
The fix is to stop touching the cache from application code entirely. Let the database tell you when something changed.
Every serious relational database exposes a change log: Postgres's WAL via logical replication slots, MySQL's binlog, MongoDB's change streams. This log fires only after a write has committed. You can't get a phantom invalidation from a rolled-back transaction. You can't miss an invalidation from a crashed process. The log is totally ordered — no interleaving races.
The pattern:
1. Application writes to the database only. No cache operation. 2. A lightweight CDC consumer tails the replication log. 3. On a change event, the consumer invalidates or repopulates the relevant cache key.
The cache is now a derived view of the database, not a parallel store you have to coordinate with it. Your application code has one job: write to the DB. Everything downstream is a consequence of that write, not a second write you have to remember.
What You Actually Get
**Ordering guarantees.** The WAL is a totally ordered log. Cache invalidations fire in commit order. You can't invalidate with data from transaction 100 and then re-populate from transaction 99. Application-level cache busting makes no such guarantee — two concurrent writers can interleave their deletes arbitrarily.
**No phantom invalidations.** A rolled-back transaction never emits a change event. Your application's try/catch doesn't catch panics, and two-phase logic breaks across process restarts. The WAL doesn't have this problem — if it's in the log, it committed.
**Decoupled write latency.** The application write completes after the DB write, full stop. Cache invalidation is async. For high-write paths, not paying a Redis round-trip on every mutation matters — you're no longer paying cache latency to serve write throughput.
The Honest Tradeoffs
CDC adds operational surface area. The consumer has to stay healthy, keep up with the replication slot, and handle backpressure gracefully. In Postgres specifically, a stalled replication slot holds WAL on disk until the consumer catches up — if your consumer dies for an hour during a write-heavy period, you're accumulating disk pressure. You need monitoring on slot lag.
Invalidation is asynchronous. Between a DB commit and the consumer firing, there's a window — typically under 100ms on a healthy system — where the cache can serve the previous value. For strict read-after-write consistency, you'd need to either route the writer's own subsequent reads directly to the DB for a grace period, or fall back to synchronous invalidation for that one caller. Neither is clean. This is a real tradeoff, not a footnote.
**Short TTLs are still the right answer for most data.** If mild staleness is acceptable, a 30-second TTL costs nothing to operate, has no race conditions worth worrying about, and requires zero infrastructure. Use CDC when stale data has a real cost: permissions checks, account balances, inventory counts, feature flag state, rate limit buckets. Don't build a CDC pipeline because a user's display name might take 200ms to propagate.
**Write-through caching** — writing to DB and cache together — sounds like it fixes the dual-write problem but it doesn't. You're still doing two writes from application code with no atomicity between them, and now your write latency includes the cache write. You've added latency and complexity without gaining consistency guarantees.
The Practical Entry Point
On Postgres, the minimum viable setup is polling pg_logical_slot_get_changes() from a small worker process and fanning out to Redis DEL calls. Managed Postgres on RDS, Supabase, and Neon all support logical replication behind a config flag. Debezium wraps this in a Kafka Connect plugin with serious operational tooling, but it's overkill unless you're already in that ecosystem — a 50-line Node.js worker hitting the slot every second is enough to break the dual-write dependency for most services.
The mental shift that matters: treat your cache as a projection, not a source. The database owns the data. The cache just makes it faster to read. Once you internalize that boundary, cache invalidation stops being a "write to two places" coordination problem and becomes a "subscribe to changes" problem — which is a far better fit for how distributed systems actually behave under failure.