Skip to content
← All writing
July 24, 2026·7 min read

Caching Strategies for App Backends

There's an old joke that the two hard things in computer science are naming, cache invalidation, and off-by-one errors. The joke survives because it's true: caching is the cheapest performance win available to a backend — often turning a 200ms database-heavy request into a 2ms lookup with a dozen lines of code — and simultaneously the most reliable source of "why is it showing old data?" bugs, the kind that can't be reproduced because the cache state that caused them is gone. The difference between the win and the bug isn't cleverness; it's knowing exactly where you're caching, for how long, and how each layer gets invalidated. This post is the map.

Cache the right distance from the user

A response can be cached at several layers, and they're complements, not competitors:

  • HTTP caching (browser/app + CDN) — the response never reaches your server at all. Biggest win, bluntest control.
  • Application caching (Valkey or in-memory) — your server answers, but skips the expensive work.
  • Database-adjacent caching — materialized views and precomputed aggregates for queries too heavy to run per-request; the database's own query planning and OS file caching are quietly doing more here than most people realize.

The closer to the user, the bigger the win and the less say you have once the response is out the door. Work from the outside in: exhaust what HTTP gives you, then add application caching for what remains hot, and only then reach for precomputation.

HTTP caching: the layer everyone forgets

Mobile developers habitually treat HTTP as uncacheable and rebuild caching by hand inside the app — a bespoke, buggy reimplementation of machinery the protocol has had for decades. The headers already exist, every client and CDN respects them, and they're good.

Cache-Control: max-age=300 tells every client and CDN the response is fresh for five minutes — perfect for data where slight staleness is invisible, like a product catalog, a public feed, or anything that changes hourly rather than secondly. Set it once on the server and every consumer of your API benefits without writing a line.

The subtler, underused tool is revalidation: your server includes an ETag header (typically a hash of the response), the client sends it back as If-None-Match, and if nothing changed you answer 304 Not Modified with an empty body. The client keeps using its cached copy; you skipped the serialization and the bandwidth. For an app that polls the same endpoints repeatedly — which many reasonably do — most responses collapse to a few header bytes, which is real savings on server load, mobile data, and battery.

One warning from the trenches, in bold because it's the worst bug in this post: anything personalized needs Cache-Control: private (or no caching at all). A shared CDN cache that stores one user's /me/dashboard and serves it to the next user is a data breach implemented by a header. It has happened to well-known companies; it is always the same missing directive; check yours today.

Application caching: cache-aside and its two rules

Inside the backend, the workhorse pattern is cache-aside: check Valkey; on a miss, fetch from the database, store the result with a TTL, return it.

value = cache.get(key)
if value is None:
    value = db.query(...)
    cache.set(key, value, ttl=300)

Two rules keep it sane over the years. First, always set a TTL — every entry, no exceptions. An entry that can only be removed by explicit invalidation will eventually be orphaned by some code path that forgot — a refactor, a new write route, an admin script — and it will serve stale data forever, unreproducibly. The TTL is your safety net; explicit invalidation is an optimization layered on top of it, never a replacement for it.

Second, design key names like a schema: user:42:profile, feed:tech:page:1 — consistent, structured, documented. Key names look like throwaway strings the day you write them; six months later they're the only handle you have for targeted invalidation ("clear everything for user 42"), and ad-hoc naming makes that impossible without flushing the world.

Good first candidates are data read hundreds of times per write: profiles, settings, product details, computed dashboards — and expensive third-party calls, where the cache saves money, not just time. An LLM response or a geocoding lookup cached for a day is a bill reduction wearing a performance costume.

Invalidation: pick staleness or pick work

When the underlying data changes, you have exactly two honest options — most caching pain comes from pretending there's a third.

Tolerate bounded staleness. Short TTLs — thirty seconds, five minutes — and no invalidation logic at all. This is deeply underrated: for most read-heavy data, users cannot perceive a minute of staleness, and the system stays trivially simple — no write-path coupling, no race conditions, nothing to forget. Ask honestly per data type: what actually breaks if this is three minutes old? For a follower count, nothing. Cache accordingly.

Invalidate on write. Where staleness is genuinely unacceptable — a user edits their profile and must see the change now, on the very next read — delete the affected keys in the same code path that writes the database. Prefer deleting the entry to updating it: let the next read repopulate from the source of truth. Updating cache and database as two separate writes invites the classic race where they land in different orders under concurrency and the cache keeps the losing value until its TTL — which you did set, right? — finally rescues you.

The pattern that keeps write-time invalidation manageable is routing all writes through one place — a repository or service layer — so the invalidation lives next to the mutation, visible in review, instead of being sprinkled across handlers where the next feature will miss one.

Two failure modes worth knowing by name

Stampedes (also "dog-piling"): a hot key expires and a thousand concurrent requests all miss together and hit the database at once — a self-inflicted load spike that arrives on a schedule. Fixes: a short lock so one request recomputes while the others briefly wait or serve the stale value, and jittered TTLs (300 seconds ± random 30) so popular keys don't expire in synchronized waves after a deploy warmed them together.

Negative lookups: "does X exist?" misses the cache every time when the answer is no — there's nothing stored to hit — so misses hammer the database indefinitely, and anyone probing nonexistent IDs can weaponize it. Cache the absence too (key → "none", short TTL), so "no" is as fast as "yes."

Measure it or you're guessing

A cache without metrics is a rumor. Track hit rate per key family and the latency of misses: a cache with a 30% hit rate might be mis-keyed, mis-TTL'd, or simply pointless, and you can't tell from the outside. Most cache clients and the server's INFO stats expose this cheaply. Five minutes of dashboard beats a week of debating whether the cache "feels" effective.

The takeaway

Start with HTTP caching and ETags — it's standards-based, benefits every client you'll ever have, and costs a few headers. Add a cache-aside Valkey layer for hot, read-heavy data, always with TTLs and schema-like key names. For each data type, choose deliberately between bounded staleness (simpler, usually right) and write-time invalidation (when freshness is the feature), and route writes through one layer so invalidation has a home. Then measure hit rates so the cache earns its keep. Caching rewards exactly the discipline it punishes you for skipping — which makes it less a trick and more a habit.

Further reading