Caching Strategies: Making Your App Feel Instant
The fastest request is the one that never reaches your server. Caching is the closest thing web development has to a free lunch — but only when you cache the right things at the right layer. Cache the wrong thing and you serve stale data; cache everything and you maintain a second database nobody asked for. Here's how we think about caching in production.
Start with the layers, top to bottom
Caching isn't one feature, it's a stack. Before writing a single line of cache code, decide which layer solves your actual problem:
- HTTP caching — the browser and CDN store full responses. Perfect for public, rarely-changing pages (blog posts, landing pages, product pages).
- Full-page cache — the server stores the rendered HTML. Great for anonymous traffic on content-heavy sites.
- Query/result cache — store the output of expensive database queries or API calls. The workhorse of most web apps.
- Object cache — store computed values, session data, rate-limit counters. Redis or Memcached territory.
The rule of thumb: cache as close to the user as possible. A CDN edge cache serves a million users with zero load on your origin. A query cache still costs a round trip to Redis.
The 80/20 of what to cache
Most apps have a handful of hot paths that generate 80% of the load. Find them before you build a caching layer — usually they're the queries that run on every page view:
- Reference data — countries, currencies, plan features, config. Read constantly, change rarely. Cache forever, invalidate on write.
- Expensive aggregations — dashboard stats, report totals, "top N" lists. Cache for minutes, not milliseconds.
- Third-party API responses — weather, exchange rates, pricing. Cache with a TTL that matches how often the data actually changes.
// Laravel — cache the result of an expensive query with a TTL
$stats = Cache::remember('dashboard:stats', 300, function () {
return Order::query()
->selectRaw('DATE(created_at) as day, COUNT(*) as total')
->groupBy('day')
->get();
});
Cache invalidation is a design problem
The famous quote is "there are only two hard things in computer science: cache invalidation and naming things." The practical version: a TTL is a promise that data will be eventually correct. If your app can tolerate five minutes of staleness, a TTL is the simplest correct answer.
If it can't, you need active invalidation — and the cleanest pattern is to invalidate on write, not on read:
- Tag-based invalidation — give cached items tags (
user:42,orders) and flush by tag when anything under that tag changes. Laravel's cache tags (Redis) do this natively. - Model events — clear the related cache when a model is saved or deleted, in the same transaction that changes the data.
- Version keys — bump a version number in the cache key when the schema or logic changes:
stats:v2:2026-09-10. Old entries expire naturally instead of being actively hunted down.
// Invalidate on write — the cache can never be older than the data
Order::created(function (Order $order) {
Cache::tags(['dashboard'])->flush();
});
The failure modes nobody warns about
- Cache stampede — a popular key expires, and a hundred requests all recompute it at once, taking the database down. Fix: cache the old value while refreshing, or use a lock so only one process recomputes.
- Stale data in the UI — a dashboard that shows yesterday's numbers with no indicator is worse than a slow one. If you cache, show the user when the data was last updated.
- Caching per-user data globally — the classic leak. If a cached response contains user-specific content, it must never be served to someone else. When in doubt, keep user-scoped data out of shared caches entirely.
What we don't cache
Sessions, shopping carts, and anything user-specific belong in a fast store (Redis), not a cache — they're state, not cacheable output. And we don't cache what we can't invalidate: if a page mixes personalized and public content, we cache the public fragments and render the personalized part fresh.
The payoff
Done right, caching is invisible: pages load in tens of milliseconds, the database stays calm during traffic spikes, and the CDN absorbs the load you'd otherwise pay a bigger server for. The discipline is small — pick the layer, cache the hot paths, invalidate on write — but it's the difference between an app that survives a spike and one that falls over at the worst possible moment.