---
title: "08 — Caching — Trading Freshness for Speed, Deliberately"
uid: backend-caching
tags: ["roadmap:backend", "memcached", "redis", "caching", "performance", "client-side-caching"]
excerpt: "Caching is a deliberate trade of freshness for speed. Every cache decision is two answers: what layer holds the copy, and how do we know when it's stale."
date: 2026-08-13T03:28:26+0000
source: https://www.aveshina.my.id/en/blog/backend-caching
---

"Just add Redis and it'll be fast" was my caching philosophy, and it produced either no speedup or stale data. The fix was seeing the trade underneath: **caching is a deliberate trade of freshness for speed, and every cache decision is really two answers — what layer holds the copy, and how do we know when it's stale.** [1] Get either wrong and you either gain nothing or serve wrong data.

The frame that helped is that a cache is a second copy of data kept closer to where it's needed, so the slow original doesn't have to be touched. Every layer of the backend stack can hold a cache — the browser, a CDN, a reverse proxy, an in-memory store, even the database's own buffer pool. The question is never "should I cache" (you almost always should, somewhere); it's "where, and how do I invalidate it."

```figure
<svg viewBox="0 0 740 320" xmlns="http://www.w3.org/2000/svg" class="my-6 w-full max-w-2xl" role="img" aria-label="Three horizontal cache layers stacked vertically. Top: client-side (browser/HTTP cache). Middle: server-side in-memory (Redis/Memcached). Bottom: the origin database. A request arrow enters at the top; at each layer it either short-circuits with a green 'HIT' or continues down with a red 'MISS'. A side label reads 'every layer trades freshness for speed'.">
  <defs>
    <marker id="carrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto">
      <path d="M0,0 L10,5 L0,10 z" fill="#64748b"/>
    </marker>
  </defs>
  <g font-family="ui-sans-serif, system-ui, sans-serif" text-rendering="geometricPrecision">

    <!-- request arrow in -->
    <text x="60" y="35" font-size="11" font-weight="700" fill="#64748b">request</text>
    <line x1="100" y1="40" x2="300" y2="40" stroke="#64748b" stroke-width="2" marker-end="url(#carrow)"/>

    <!-- Layer 1: client -->
    <rect x="120" y="55" width="500" height="60" rx="10" fill="#e0e7ff" stroke="#6366f1" stroke-width="1.5"/>
    <text x="370" y="80" font-size="13" font-weight="700" fill="#1e1b4b" text-anchor="middle">Client-side cache — browser / HTTP cache</text>
    <text x="370" y="100" font-size="10" fill="#475569" text-anchor="middle">HTTP headers (Cache-Control, ETag), service workers</text>
    <rect x="540" y="68" width="60" height="24" rx="6" fill="#dcfce7" stroke="#16a34a"/>
    <text x="570" y="84" font-size="10" font-weight="700" fill="#052e16" text-anchor="middle">HIT?</text>

    <!-- miss arrow down -->
    <line x1="370" y1="115" x2="370" y2="140" stroke="#dc2626" stroke-width="1.5" stroke-dasharray="3 3"/>
    <text x="380" y="132" font-size="9" fill="#dc2626">miss</text>

    <!-- Layer 2: server in-memory -->
    <rect x="120" y="145" width="500" height="60" rx="10" fill="#fef9c3" stroke="#ca8a04" stroke-width="1.5"/>
    <text x="370" y="170" font-size="13" font-weight="700" fill="#422006" text-anchor="middle">Server-side cache — Redis / Memcached</text>
    <text x="370" y="190" font-size="10" fill="#475569" text-anchor="middle">in-memory key-value, sub-millisecond reads</text>
    <rect x="540" y="158" width="60" height="24" rx="6" fill="#dcfce7" stroke="#16a34a"/>
    <text x="570" y="174" font-size="10" font-weight="700" fill="#052e16" text-anchor="middle">HIT?</text>

    <!-- miss arrow down -->
    <line x1="370" y1="205" x2="370" y2="230" stroke="#dc2626" stroke-width="1.5" stroke-dasharray="3 3"/>
    <text x="380" y="222" font-size="9" fill="#dc2626">miss</text>

    <!-- Layer 3: origin -->
    <rect x="120" y="235" width="500" height="60" rx="10" fill="#fce7f3" stroke="#db2777" stroke-width="1.5"/>
    <text x="370" y="260" font-size="13" font-weight="700" fill="#500724" text-anchor="middle">Origin — the database (slow, authoritative)</text>
    <text x="370" y="280" font-size="10" fill="#500724" text-anchor="middle">the source of truth</text>

    <!-- side label -->
    <text x="690" y="180" font-size="10" font-style="italic" fill="#64748b" text-anchor="end" transform="rotate(0 690 180)">trade: freshness ▸ speed</text>
  </g>
</svg>
```

## The layers, top to bottom

**Client-side caching** is the outermost layer — the browser itself holds copies of responses based on HTTP caching headers [4][5]. Cache-Control: max-age=3600 tells the browser the response is fresh for an hour; ETag lets it check whether its cached copy is still valid with a lightweight conditional request. This layer is the cheapest cache (the data is already where it's needed; no network at all on a hit), and it's controlled entirely by response headers the server sets.

**Server-side in-memory caching** is the layer I'd add when client-side caching isn't enough — when many clients need the same expensive computation, or when the data changes more often than the browser can tolerate but less often than every request. This is where **Redis** and **Memcached** live [2][3].

- **Redis** is an in-memory data structure store — strings, lists, sets, hashes, sorted sets — with optional persistence, replication, and clustering. It's not just a cache; it can be a session store, a message broker, a rate limiter. Sub-millisecond reads. Redis is the modern default for server-side caching because of its rich data types and durability options.
- **Memcached** is the simpler predecessor — a distributed in-memory hash table, intentionally limited to key-value strings, with LRU eviction. It does one thing (cache) very well and is lighter than Redis. For pure key-value caching with no need for data structures or persistence, Memcached is still a fine choice.

**The database's own caches** are the innermost layer — Postgres/MySQL buffer pools, query caches — which I tune rarely and almost never override at the application level.

## The harder question: invalidation

The hard part of caching isn't storing the copy; it's knowing when it's wrong. There's a famous line that "there are only two hard problems in computer science: cache invalidation and naming things." The roadmap's caching material gestures at the strategies, and they cluster into a few patterns:

- **TTL (time-to-live).** Each cached entry expires after a fixed duration. Simple, robust, and stale-by-design — the data may be up to TTL seconds old. Best for data that changes slowly and tolerates some lag (a blog post body, a config value).
- **Write-through / explicit invalidation.** Whenever the underlying data changes, the code updates or deletes the cache entry immediately. Fresh, but requires every write path to remember to invalidate — miss one and you serve stale data indefinitely.
- **Tag-based invalidation.** Cache entries are tagged with categories; when a category changes, all entries with that tag are purged at once. This is what I use in the portfolio (revalidateTag('prismic:blog') purges all blog data) because it groups related entries under one invalidation call.

The choice is a freshness/complexity trade-off. TTL is simplest and stale-tolerant; explicit invalidation is freshest and bug-prone; tag-based sits between. The worst cache bug is silent staleness — the user sees old data and nobody realizes it's wrong. That's why I prefer TTL with a short ceiling even when I also do explicit invalidation: a stale entry can't outlive its TTL.

## Client-side caching specifics: HTTP headers

Because client-side caching is free and header-driven, it's worth knowing the two headers that matter most:

- **Cache-Control** — the modern directive. max-age=N sets freshness in seconds; public vs private controls whether shared caches (CDNs) can store it; no-cache means "revalidate every time"; no-store means "don't cache at all."
- **ETag / If-None-Match** — a content hash. The server sends an ETag with the response; the browser sends it back as If-None-Match on the next request. If the content hasn't changed, the server replies 304 Not Modified with no body — saving bandwidth but still costing a round trip.

The portfolio uses Next.js's ISR (Incremental Static Regeneration), which is essentially server-side caching with TTL plus tag-based invalidation — pages are statically generated, served from cache, and revalidated either on a timer or when a tag is purged. It's the same patterns, packaged.

## How I use this

Three rules capture the practical takeaway:

- **Cache at the outermost layer that can serve the request.** Browser cache before CDN cache before server cache before DB. The earlier the hit, the cheaper.
- **Default to TTL with a short ceiling, layer explicit invalidation on top.** TTL guarantees eventual freshness even when an invalidation path is missed; explicit invalidation keeps the typical case fresh.
- **Measure before adding Redis.** A slow endpoint is often a missing index or an N+1, not a missing cache. Caching a broken query hides the problem and adds a second source of truth to manage. Fix the query first; cache the result once it's actually expensive.

The framing — *freshness for speed, deliberately* — is what keeps caching disciplined. A cache is a contract with the user that the data they see may be slightly old, in exchange for it being fast. Making that trade deliberately, at the right layer, with a real invalidation story, is the whole skill.

## References

[1] Cloudflare, "What is caching?," 2024. [Online]. Available: [https://www.cloudflare.com/en-gb/learning/cdn/what-is-caching/](https://www.cloudflare.com/en-gb/learning/cdn/what-is-caching/)

[2] Redis Ltd., "Redis Documentation." [Online]. Available: [https://redis.io/docs/latest/](https://redis.io/docs/latest/)

[3] "memcached/memcached," GitHub. [Online]. Available: [https://github.com/memcached/memcached#readme](https://github.com/memcached/memcached#readme)

[4] Redis Ltd., "Client Side Caching." [Online]. Available: [https://redis.io/docs/latest/develop/use/client-side-caching/](https://redis.io/docs/latest/develop/use/client-side-caching/)

[5] "Top Caching Strategies Explained," ByteByteGo. [Online]. Available: [https://blog.bytebytego.com/p/top-caching-strategies](https://blog.bytebytego.com/p/top-caching-strategies)

```quiz
Q: Every cache decision is really two answers. What are they?
- What layer holds the copy, and how do we know when it's stale
- How much RAM the server has, and which CDN to use
correct: 0
explain: The layer (browser, CDN, Redis, DB) determines how close the copy is to the need; the invalidation strategy (TTL, write-through, tag-based) determines freshness. Both must be answered or the cache is either useless or wrong.

Q: What is the core trade-off a cache makes?
- Freshness for speed — the cached copy may be older than the source of truth
- Correctness for storage — cached data is allowed to be wrong in exchange for disk space
correct: 0
explain: A cache serves a copy that may lag the origin, in exchange for faster reads. The whole skill is making that lag bounded (TTL ceiling) and usually minimal (explicit invalidation), so the user gets speed without unacceptable staleness.

Q: Why is Redis often preferred over Memcached for modern server-side caching?
- Redis supports rich data types (lists, sets, hashes), persistence, and replication, while Memcached is a simpler key-value store
- Redis is faster than Memcached in every benchmark
correct: 0
explain: Redis offers data structures, durability, and clustering, making it suitable as cache, session store, rate limiter, and message broker. Memcached is intentionally limited to key-value strings. For pure simple caching Memcached is still fine; Redis is the broader tool.

Q: A browser sends If-None-Match: "abc123" and the content hasn't changed. What does the server reply?
- 304 Not Modified with no body
- 200 OK with the full response body
correct: 0
explain: ETag-based revalidation: the server compares the If-None-Match value to the current ETag. If they match (unchanged), it replies 304 Not Modified — the browser uses its cached copy, saving bandwidth while still costing a round trip.

Q: You discover a slow endpoint and your first instinct is to add Redis. Why might this be the wrong move?
- The slowness may be a missing database index or an N+1 query; caching a broken query hides the problem and adds a second source of truth
- Adding Redis is always wrong; caching should be avoided
correct: 0
explain: Many slow endpoints are caused by query problems (no index, N+1) that caching masks rather than fixes. Fix the query first; once the underlying work is genuinely expensive, cache the result. Premature caching adds invalidation bugs to unsolved performance problems.
```
