ServerSide Caching vs ClientSide Caching
Server-side caching and client-side caching both store data temporarily so an application answers requests faster and moves less data over the network. The distinction is where the copy lives and who controls its lifetime. Getting this split right is one of the highest-leverage performance decisions you make, because the two caches sit on opposite ends of the same request and solve genuinely different problems.
The core distinction
- Server-side cache lives inside your infrastructure (an in-memory store like Redis/Memcached, an application object cache, or a CDN edge). One copy can be reused across all users. You control invalidation directly, so it can be aggressively fresh.
- Client-side cache lives on each user's device (the browser HTTP cache,
localStorage/IndexedDB, or a mobile app's on-disk store). It is per user, saves the round trip entirely, and is the only cache that works offline — but you can only influence it through response headers; you cannot force it to drop a stale copy.
A production system almost always uses both, layered. The request below descends through a private browser cache, a shared CDN edge, and finally the origin. Each layer that answers a request removes work and latency from every layer behind it.
The response headers that drive both caches
HTTP caching is a contract the origin declares in response headers; every cache in the path obeys it. Four directives carry most of the weight:
max-age=N— the response is fresh for N seconds. Private caches (the browser) key their freshness off this value.s-maxage=N— freshness for shared caches only (CDN edge, reverse proxy). It overridesmax-agefor those caches and is ignored by the browser. This is what lets you keep a long edge TTL while browsers recheck often.publicvsprivate—publicpermits a shared cache to store the response;privateforbids it, so only the end user's browser may keep a copy. A CDN must not store aprivateresponse.ETag+If-None-Match— a version fingerprint. When a cached copy goes stale, the cache sends a conditional request carryingIf-None-Match: "v7". If the content is unchanged the server replies304 Not Modifiedwith no body — revalidation that saves the payload, though not always the round trip.
The key mechanical rule to internalize: a cache only forwards a request when its own copy is stale. A fresh shared cache answers the conditional request from its own stored copy and never contacts the origin. The timeline and trace below make this concrete for one object served with public, max-age=30, s-maxage=300.
Worked trace: one object, four moments
Same URL /product/42, served with Cache-Control: public, max-age=30, s-maxage=300 and ETag: "v7". Assume the browser↔edge round trip is about 20 ms and the edge↔origin round trip about 180 ms (so a full browser↔origin trip is about 200 ms).
| Time | Browser cache (max-age=30) | CDN edge (s-maxage=300) | Reaches origin? | Cost | Body |
|---|---|---|---|---|---|
| t=0 (cold) | Miss → forward | Miss → fetch origin, then store | Yes, full render | ~200 ms | 40 KB |
| t=10 s | Fresh (age 10 < 30) → local hit | Not contacted | No | ~1 ms | 0 (from disk) |
| t=50 s | Stale (age 50 > 30) → conditional If-None-Match: "v7" to edge | Fresh (age 50 < 300) → answers 304 from its own copy | No | ~20 ms (edge hop) | 0 (304) |
| t=340 s | Stale → conditional to edge | Stale (age 340 > 300) → revalidates to origin with If-None-Match: "v7" | Yes, origin replies 304 (unchanged) | ~200 ms (full round trip) | 0 (304) |
What each row teaches
- t=10 (local hit): the client-side cache is the only one that removes the network entirely. Nothing beats it for latency, and it is the only layer that would still work offline.
- t=50 (304 at the edge): the browser copy is stale, but the shared edge copy is still fresh, so the edge satisfies the conditional itself. This is a fast ~20 ms edge hop with an empty body — the origin is never touched. A fresh shared cache never revalidates upstream.
- t=340 (304 from the origin): now both copies are stale, so the edge genuinely revalidates to the origin. The
304here saved the 40 KB body but not the ~200 ms round trip. Revalidation trades bandwidth, not latency, once every layer is stale.
The lesson: browsers watch max-age, shared caches watch s-maxage, and a 304 is only cheap in wall-clock time when a nearby cache is still fresh enough to answer it.
Server-side caching in depth
Server-side caching keeps one shared copy inside your infrastructure, in front of the expensive work — a database query, a template render, or a downstream API call. The dominant read pattern is cache-aside (lazy loading): the application checks the cache, and on a miss it loads from the source of truth and populates the cache with a TTL.
Cache-aside in Go (go-redis v9)
// import ("context"; "database/sql"; "encoding/json"; "fmt"; "time"
// "github.com/redis/go-redis/v9")
func getProduct(ctx context.Context, rdb *redis.Client, db *sql.DB, id int) (Product, error) {
key := fmt.Sprintf("product:%d", id)
// 1) Try the cache first.
b, err := rdb.Get(ctx, key).Bytes()
if err == nil {
var p Product
if jerr := json.Unmarshal(b, &p); jerr == nil {
return p, nil // cache hit
}
// corrupt entry: fall through and reload
} else if err != redis.Nil {
return Product{}, err // a real Redis error, not just a miss
}
// 2) Miss (redis.Nil): read the source of truth.
p, err := loadProductFromDB(ctx, db, id)
if err != nil {
return Product{}, err
}
// 3) Best-effort populate for next time, with a TTL.
if data, jerr := json.Marshal(p); jerr == nil {
_ = rdb.Set(ctx, key, data, 5*time.Minute).Err()
}
return p, nil
}These are real go-redis v9 signatures: Get(ctx, key) returns a *StringCmd whose .Bytes() yields ([]byte, error); a miss surfaces as the sentinel redis.Nil (which must be distinguished from a genuine error); and Set(ctx, key, value, expiration) returns a *StatusCmd exposing .Err().
The same pattern in Java (Jedis)
public Product getProduct(long id) throws Exception {
String key = "product:" + id;
// 1) Try the cache; Jedis#get returns null on a miss.
String cached = jedis.get(key);
if (cached != null) {
return objectMapper.readValue(cached, Product.class);
}
// 2) Miss: load from the database.
Product p = productRepository.findById(id);
// 3) Populate with a TTL of 300 seconds.
jedis.setex(key, 300, objectMapper.writeValueAsString(p));
return p;
}Common server-side layers: object/query caches (Redis, Memcached), full-page or fragment caches (Varnish, application-level), and CDN edge caches (which are themselves shared server-side caches you configure via response headers).
Pros: faster responses and, more importantly, far less load on the database and backend; one warmed entry serves every user. Cons: extra memory/infrastructure to run and monitor, and you own the hard problem of invalidation — a shared stale entry is wrong for everyone at once.
Client-side caching in depth
Client-side caching stores data on each user's own device, so a repeat read costs zero network. It is controlled by the client, with the server only influencing it through headers.
- Browser HTTP cache — governed automatically by
Cache-Control/ETag; ideal for static assets (images, CSS, JS) fingerprinted with a content hash so they can be cached for a year and busted by renaming. localStorage/sessionStorage— small (~5 MB) synchronous key/value string storage for preferences and tokens.- IndexedDB — larger, asynchronous, structured storage for offline-capable apps and PWAs.
- Mobile on-disk stores — e.g. a weather app persisting the last forecast so it renders instantly (and offline) on reopen, then refreshes in the background.
Pros: the lowest possible latency (no round trip at all), reduced bandwidth, and genuine offline access. Cons: limited by device storage; per user, so it never offloads your database the way a shared cache does; and you cannot force eviction — once you have handed out a copy with a long max-age, you must wait it out or change the URL. This is exactly why fingerprinted asset filenames exist.
Choosing between them (the judgment layer)
| Dimension | Server-side cache | Client-side cache |
|---|---|---|
| Location | Your infrastructure (Redis, Varnish, CDN edge) | The user's device (browser, app storage) |
| Scope | Shared across all users | Per individual user |
| Removes the network round trip? | No — reduces it (nearer/cheaper hop) | Yes — a local hit costs zero network |
| Offloads the database? | Yes, strongly | Only for repeat reads by the same user |
| Works offline? | No | Yes |
| Invalidation control | Direct and immediate | Indirect (headers) — cannot force eviction |
| Good for | Expensive shared computation, hot rows, personalized-but-reused data | Static assets, per-user UI state, offline data |
When to reach for which
- Use a server-side cache when many users read the same expensive result (a product page, a trending feed) and you need tight control over freshness. It is the right tool when the goal is to protect the database.
- Use a client-side cache when the win is per-user latency and bandwidth on immutable-ish assets, or when offline behavior matters. Fingerprint the URLs so you keep the ability to invalidate.
- Use both, layered for anything user-facing and cacheable: long-lived immutable assets on the client and CDN, short
max-age+ longers-maxage+ETagrevalidation for HTML/JSON that changes.
Trade-offs versus the obvious alternatives
- vs. no cache: caching trades correctness risk (staleness) for latency and load. Never cache data that must be strictly correct on every read (account balances, inventory at checkout) without an explicit invalidation path.
- Client-side vs. server-side as substitutes: they are not interchangeable. A client cache cannot protect your database from a thundering herd of first-time or cache-busted users; a server cache cannot give you offline or a zero-network read. Reaching for one when you needed the other is the classic mistake.
- Long TTL vs. revalidation: a long
max-agemaximizes hit rate but makes stale data unfixable;ETagrevalidation keeps correctness at the cost of a conditional round trip when copies go stale (as the t=340 row showed).
Pitfalls and invalidation
- Never store a
privateor per-user response in a shared cache. Mixingpubliccaching with user-specific content leaks one user's data to another. If a CDN edge must cache it, it must bepublicand identical for every user — otherwise mark itprivateand let only the browser keep it. The defenses layer in this order. (1) For requests carryingAuthorization, RFC 9111 §3.5 already forbids a shared cache from reusing the response at all — unless the response explicitly opts in with a directive likepublic,s-maxage, ormust-revalidate— so never add those to per-user responses. (2) Mark genuinely per-user responsesprivateso only the browser stores them. (3) UseVaryfor legitimate shared variants (Vary: Accept-Language, andVary: Cookiewhere session cookies change the render) so the edge keys a separate copy per variant instead of handing one user's rendered page to the next. A missingprivate/Varyon a cookie-personalized response is the canonical cross-user cache leak. - Stale-everywhere risk of shared caches: a bad server-side entry is wrong for all users simultaneously. Pair TTLs with explicit invalidation (delete-on-write, or write-through) for data that changes.
- Thundering herd on expiry: when a hot key expires, many requests miss at once and stampede the database. Mitigate with request coalescing / single-flight, or serve-stale-while-revalidate.
- Unbustable client copies: content served with a long
max-agecannot be pulled back. Version asset URLs by content hash so a change ships a new URL rather than waiting out the TTL. - Cache invalidation is genuinely hard — the safest default is a short-ish TTL plus
ETagrevalidation, escalating to explicit invalidation only where you need immediate correctness.
Sources
- MDN Web Docs — HTTP caching (
Cache-Control, freshness,public/private,max-age/s-maxage, conditional requests and304): https://developer.mozilla.org/en-US/docs/Web/HTTP/Caching - RFC 9111 — HTTP Caching (normative rules for shared vs. private caches, freshness, and validation): https://www.rfc-editor.org/rfc/rfc9111
- MDN Web Docs — ETag and If-None-Match: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/ETag
- go-redis v9 documentation (
Get,Set,redis.Nilsemantics): https://redis.uptrace.dev/ - Redis docs — Client-side caching and cache-aside patterns: https://redis.io/docs/latest/develop/use/client-side-caching/
🤖 Don't fully get this? Learn it with Claude
Stuck on ServerSide Caching vs ClientSide Caching? Open Claude, copy a block below, and it'll teach you this exact concept — visually and interactively.
Build the mental picture, not memorization.
I just read a lesson on **ServerSide Caching vs ClientSide Caching** (System Design) and want to truly understand it. Explain ServerSide Caching vs ClientSide Caching from first principles using ONE vivid real-world analogy and a visual mental model — draw it as ASCII art or a clear step-by-step diagram — with a concrete example using real numbers. Then ask me one question to check I got the mental picture, and wait for my reply. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
Socratic — adapts to where you're stuck.
Teach me **ServerSide Caching vs ClientSide Caching** interactively. Ask me ONE guiding question at a time, wait for my answer, and adapt to my confusion — build the idea with me step by step instead of explaining it all at once. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
Active recall exposes what you missed.
Quiz me on **ServerSide Caching vs ClientSide Caching** with 5 questions, easy to tricky, ONE at a time. Tell me if each answer is right; at the end, explain clearly what I got wrong and why. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
Intuition + hook + flashcards for long-term memory.
Help me remember **ServerSide Caching vs ClientSide Caching** for the long term: give the one-sentence intuition, a memorable hook/mnemonic, a tiny worked example, and 3 active-recall flashcards (Q -> A). If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.