Caching is a performance tool, not a default layer for every read. Used in the wrong place it serves stale prices, stampedes the database when TTLs align, or hides an unaffordable query until the day the cache is cold. This article walks three failure modes with a small Node lab, then a decision checklist for when to skip the cache entirely.

Run the lab

node content/labs/when-not-to-cache/demo.mjs

You should see: a stale read after write, a concurrent miss count greater than one, and arithmetic for a miss-storm load spike.

Case 1 — Stale reads after writes

Pattern: TTL cache in front of a value that users can change and immediately re-read (prices, permissions, feature entitlements).

Lab output shape:

read { value: 100, source: 'cache' }
write db price=80
read { value: 100, source: 'cache' }  ← still 100 if TTL-only

Why it hurts: The system is “fast” and wrong. Support tickets look like heisenbugs.

Prefer instead:

  • Read-your-writes via primary / bypass cache on the mutating session
  • Explicit invalidation on write (delete key in the same transaction boundary you can afford)
  • Short TTL only when staleness is business-acceptable and stated

Metric that reveals it: compare “update timestamp” vs “value shown” in support tooling; track conflict rate between write API and subsequent read API.

Case 2 — Thundering herd on expiry

Pattern: popular key expires; many requests miss together; each misses independently to the database.

Lab simulation: 20 concurrent readers after expiry → dbHits often near 20 without single-flight.

Why it hurts: The cache does not protect peak load at the exact moment keys expire. CPU and DB pool usage spike on a schedule.

Prefer instead:

  • Single-flight / request coalescing per key
  • Soft TTL (serve stale while one refresher runs)
  • Jittered TTLs so keys do not align
  • Proactive refresh for top-N keys

Metric that reveals it: periodic QPS spikes to the DB matching TTL; cache miss ratio sawtooth.

Case 3 — Hidden load and miss storms

Pattern: 99% hit ratio makes an expensive query look free. A flush, deploy, or regional failover drops hit ratio to ~0% and multiplies DB QPS by 100×.

Lab arithmetic at 1000 QPS and 99% hits:

ModeApprox DB QPS
Steady10
Cold cache1000

Why it hurts: Capacity planning uses the sunny-day graph. The first cold start becomes an outage.

Prefer instead:

  • Make the underlying query cheap enough to survive miss storms
  • Cache only after the query is acceptable without cache
  • Staged cache warm on deploy
  • Load-test with cold cache, not only warm

Metric that reveals it: DB CPU and pool wait during deploys; “cache hit ratio” as a leading indicator, not a vanity chart.

Decision framework

Cache when all of these hold:

  1. Stale data is acceptable for a known bound, or you have correct invalidation.
  2. The key space and TTL will not create synchronized stampedes—or you have single-flight.
  3. The origin can survive a realistic miss storm (load-tested).
  4. You have metrics: hit ratio, origin QPS, staleness complaints, error rate.

Skip cache when:

  • Strong read-after-write is required and invalidation is harder than optimizing the query
  • The data is already in memory in-process for the request
  • The query is cheap and rare
  • Correctness risk exceeds latency gain (authz, balances, inventory final checks)

What to use instead of a blanket Redis get/set

NeedTool
Faster queryIndex, covering index, denormalized read model
Less remote chatterBatch/APIs, DataLoader-style coalescing
Burst absorptionQueue + backpressure, not infinite cache
Session stickinessSticky reads to primary for a short window

Monitoring for cache harm

  • Staleness: user-visible version vectors or “updated_at” mismatches
  • Herd: origin QPS spikes at TTL boundaries
  • Hidden load: alert on origin QPS, not only app latency
  • Deploy health: hit ratio + DB saturation in the first N minutes after release

Summary

Caching is optional infrastructure. The three lab cases—stale writes, herds, hidden load—show up in production more often than “we forgot to add Redis.” Start from a query and correctness requirement; add a cache only when you can name the invalidation story and survive a cold start.