Skip to content

Zoom-in: Cache

Karify98·
Cover Image for Zoom-in: Cache

It's slow, so add Redis. App gets faster, problem solved.

graph LR
    C(["👤 Client"]) -->|"request"| A(["⚡ App"])
    A -->|"fast response"| C
    style C fill:#1e293b,stroke:#475569,color:#cbd5e1
    style A fill:#1e293b,stroke:#475569,color:#cbd5e1

Zoom in on the black box.


Layer 1 — The root problem: every request is expensive

Why cache? Because some operations cost time or resources — and the result doesn't change between calls.

sequenceDiagram
    participant C as Client
    participant A as App
    participant DB as Database

    C->>A: GET /products/top-10
    A->>DB: SELECT TOP 10 products ORDER BY sales
    Note over DB: Scanning 10 million rows...
    DB-->>A: Result (800ms)
    A-->>C: List (800ms)

    C->>A: GET /products/top-10 (second request)
    A->>DB: SELECT TOP 10 products ORDER BY sales
    Note over DB: Scanning 10 million rows again...
    DB-->>A: Same result (800ms)
    A-->>C: Same list (800ms)

Both requests return identical results — but each costs 800ms. The top 10 products don't change every second.

Problem left: need to store the result so the next request doesn't recompute from scratch.

Layer 2 — Cache hit and miss: the basic mechanism

Cache follows a simple loop: check first, use it if found, compute and store if not.

graph TD
    R["Request"] --> Q{"Cache has\nresult?"}

    Q -->|"Cache hit ✓"| H["Return result immediately\n(few ms)"]
    Q -->|"Cache miss ✗"| M["Compute result\n(800ms)"]
    M --> S["Store in cache"]
    S --> H2["Return result"]

    style R fill:#1e3a5f,stroke:#3b82f6,color:#93c5fd
    style Q fill:#3b2a1a,stroke:#f59e0b,color:#fcd34d
    style H fill:#1a3a2a,stroke:#22c55e,color:#86efac
    style H2 fill:#1a3a2a,stroke:#22c55e,color:#86efac
    style M fill:#3b1a1a,stroke:#ef4444,color:#fca5a5
    style S fill:#1e293b,stroke:#475569,color:#cbd5e1

Cache can't hold data forever — memory is finite and data changes. Rules are needed for how long data stays fresh and what to drop when space runs out.

Problem left: cached data can go stale — need a strategy for expiry and eviction.

Layer 3 — TTL and eviction: managing cache lifecycle

TTL (Time-To-Live) is how long an entry stays in cache before automatically expiring.

SET product:top10 <data> EX 300    # expires in 5 minutes

When the cache is full, something must be dropped — this is the eviction policy:

LRU Least Recently Used — evict the entry that hasn't been accessed for the longest time. Most common because it matches real-world access patterns.
LFU Least Frequently Used — evict the entry accessed the fewest times. Better than LRU when hot data is accessed at a steady rate.
TTL Expire first — prioritize dropping expired entries. Redis defaults combine this with LRU/LFU.

Short TTL → fewer stale reads, more cache misses. Long TTL → fewer DB queries, but older data. There's no "right" TTL — it depends on how much staleness the use case can tolerate.

Problem left: data changes between TTL expirations — users see stale data without knowing it.

Layer 4 — Cache invalidation: the hardest problem

Phil Karlton once said: "There are only two hard things in Computer Science: cache invalidation and naming things."

When source data changes, the cache must be updated — but the cache doesn't know that on its own.

sequenceDiagram
    participant A as Admin
    participant App as App
    participant Cache as Redis Cache
    participant DB as Database

    A->>App: PUT /products/1 (update price)
    App->>DB: UPDATE products SET price = 99
    DB-->>App: OK

    Note over Cache: Cache still holds stale data with price 149

    A->>App: GET /products/top-10
    App->>Cache: Fetch top-10
    Cache-->>App: Stale data (price 149)
    App-->>A: ❌ Wrong price

Three common strategies:

  • Cache-aside + TTL: let TTL expire naturally. Simplest, accepts staleness within the TTL window.
  • Write-through: update cache immediately when updating the DB. More consistent, more complex.
  • Event-driven invalidation: when DB changes, emit an event to delete the related cache entry. Most flexible, but requires additional infrastructure.

Full picture

A request traversing cache layers before reaching the database.

graph LR
    C["👤 Client"] --> BC["Browser Cache\n(Service Worker / HTTP cache)"]
    BC -->|"miss"| CDN["🌐 CDN\n(CloudFront, Cloudflare)"]
    CDN -->|"miss"| AC["⚡ App Cache\n(Redis, Memcached)"]
    AC -->|"miss"| DB["🗄️ Database"]

    DB -->|"result"| AC
    AC -->|"store + return"| CDN
    CDN -->|"store + return"| BC
    BC -->|"store + return"| C

    style C fill:#1e3a5f,stroke:#3b82f6,color:#93c5fd
    style BC fill:#3b2a1a,stroke:#f59e0b,color:#fcd34d
    style CDN fill:#3b2a1a,stroke:#f59e0b,color:#fcd34d
    style AC fill:#3b2a1a,stroke:#f59e0b,color:#fcd34d
    style DB fill:#1a3a2a,stroke:#22c55e,color:#86efac

Each layer solves a different problem: browser cache eliminates network round trips; CDN reduces geographic latency; app cache reduces DB load. When debugging, the key question is which layer is serving the cached response.


Three common misconceptions

Redis is a database

Redis is an in-memory store — data is lost on restart unless persistence is enabled. Use Redis for cache (loss is acceptable) or sessions (with an explicit TTL), not for critical data that needs to survive long-term.

More cache is always better

Cache keys need management: too many entries leads to unpredictable eviction; caching frequently-changing data leads to constant staleness. Cache works best for data that's read often, changes rarely, and can tolerate being a few seconds to minutes behind.

Deleting the cache is enough to update it

Cache deletion (invalidation) isn't atomic with the DB update — the window between deleting the cache and committing to the DB can let another request read stale data and write it back into the cache. In concurrent environments, operation order matters more than most people assume.

Takeaway

Cache exists because some operations cost more than the value of perfect consistency. TTL is a commitment to the acceptable level of staleness; eviction policy is the strategy when memory runs out; cache invalidation is hard because the DB and cache are two independent systems with no awareness of each other.

When debugging wrong data, the first question is: "which cache layer is this request reading from?" — browser devtools, CDN headers (X-Cache: HIT), and Redis TTL <key> will point to the problematic layer.


Article assisted by Amy 🌸 - AI Assistant. Content reviewed by the author.

Related Posts