← Journal
· 8 min

Designing a Two-Level Cache with JCS and Redis

A local in-process cache in front of Redis buys latency and resilience. It bills you in invalidation. Here is the accounting.

CachingRedisArchitecture

Most caching write-ups stop at the read path, which is the easy half. The read path is a waterfall and it is obvious. The interesting half is what happens when the data changes.

The read path, briefly

request ──► local (JCS) ──miss──► Redis ──miss──► database
              ▲                     ▲                │
              └─────────────────────┴────────────────┘
                        populate on the way back

Local removes the network. Redis removes the cold start — a newly scaled instance warms from Redis instead of stampeding the database. The database is the only truth.

The bill: N processes to invalidate

One shared Redis has one copy to evict. A local cache in each of N instances has N copies, in N processes, that you cannot reach directly.

Three ways to handle it, in increasing order of how much I trust them:

Short TTLs only. Simple, no messaging, and every value is wrong for at most the TTL. Fine when “wrong for 30 seconds” is genuinely fine. It usually is not, for the data worth caching this hard.

Redis pub/sub fan-out. Cheap, already there — and fire-and-forget. An instance restarting during the publish never learns, and you cannot find out afterwards that it did not.

A log (Kafka). Ordered per key, replayable from an offset, and inspectable after the fact. It is more moving parts and it is the one I would ship, for the reason that it turns “is this stale?” from a guess into a query.

Whichever you pick, keep the short TTL as well. Invalidation you cannot verify should not be the only thing between you and a wrong value.

Sizing

Local caches want to be small. The value of the local layer is concentrated almost entirely in a handful of hot keys, and a large local cache mostly buys you memory pressure, longer GC pauses and more stale copies to worry about.

Measure hit rate per level, not overall. A 95% total hit rate that is 5% local and 90% Redis is telling you the local layer is not earning its invalidation cost — and that is exactly the signal to drop it.

When not to do this

  • Write-heavy data. Invalidation traffic overtakes the saving.
  • Data where staleness is a correctness bug rather than a UX blemish. Balances, limits, authorisation state.
  • Small deployments. Two instances and a healthy database do not need this, and the complexity is not free to operate.

Two-level caching earns its place for data read constantly, written rarely, and tolerant of bounded staleness. That is a narrower set than it first appears — which is the actual point.

NextWhat AI Coding Agents Still Get Wrong About Distributed Systems