Back to blog
Ai News

Caching Strategies Web Apps

11 min read

Every fast website is, underneath, a caching problem solved well. The database didn't get faster, the network didn't get shorter — someone decided which bytes could be reused, for how long, and how to know when they'd gone stale. In 2026, with edge compute, framework-level caching primitives, and CDNs that expose fine-grained control, teams have more caching knobs than ever. That's a blessing and a trap: more knobs means more ways to serve a customer's old cart total or a competitor's personalized homepage.

This post walks through the caching layers that matter for a typical web app, the strategies that hold up in production, and the invalidation patterns that keep caching from turning into a correctness bug factory.

Why caching is still the highest-leverage performance lever

Compute and bandwidth are cheap compared to a decade ago, but latency is still bounded by physics — a round trip from Karachi to a US-East origin server is going to take 200+ milliseconds no matter how big your server is. Caching is the only lever that removes that round trip entirely for a meaningful share of requests. A well-cached page load can go from "hit the origin, hit the database, render, respond" to "answered at an edge node 20ms away" — a 10x or more difference in perceived speed.

The catch is that caching doesn't remove complexity, it relocates it. Instead of "is my query correct," the question becomes "is my cache correct, and do I know the moment it isn't." That's the real skill in caching strategy: not turning it on, but knowing exactly when to turn it off.

The four layers of caching in a modern web app

1. Browser cache

The client's own cache, controlled by Cache-Control, ETag, and Last-Modified headers. This is the cheapest cache there is — zero network round trip — and it's underused. Static assets (JS bundles, images, fonts) should carry long max-age values, often measured in weeks, paired with content-hashed filenames (app.a3f9c1.js) so a new deploy naturally produces a new URL instead of requiring invalidation. If the filename never changes when the content does, you don't need to tell the browser to forget it — the old file just stops being referenced.

HTML documents are the opposite case: they should generally be cached briefly or not at all at the browser level, since they're the entry point that needs to reflect the newest state of the app.

2. CDN / edge cache

This sits between the browser and your origin server and is where most of the caching strategy work actually happens today. Modern CDNs (Cloudflare, CloudFront, Fastly) let you set edge-specific cache behavior independently of what you tell the browser. Cloudflare's CDN-Cache-Control header, for instance, controls edge caching without affecting how the browser itself caches the response — useful when you want a resource cached for minutes at the edge but not at all on the client, or vice versa.

The workhorse directive here is stale-while-revalidate:

Cache-Control: public, max-age=60, stale-while-revalidate=300

This tells the CDN: serve the cached response for up to 60 seconds without question; after that, for up to 5 more minutes, keep serving the (now stale) cached copy immediately while fetching a fresh one in the background for the next request. Users never wait on a cache miss — they get an instant response, and the cache self-heals asynchronously. Amazon CloudFront and most modern CDNs support this directive natively, along with a companion stale-if-error that keeps serving the last good cached response if the origin starts erroring, which is a quietly excellent resilience pattern during an origin outage.

Recommended TTL ranges by content type, as a rough starting point:

  • Static assets (images, fonts, compiled JS/CSS): weeks, with hashed filenames
  • HTML pages: seconds to minutes, depending on how dynamic the content is
  • API responses backing UI that tolerates slight staleness: seconds to low minutes
  • API responses backing anything transactional (pricing, inventory, auth state): effectively uncached, or cached with very aggressive invalidation

3. Application-level cache (Redis, in-memory)

This is the layer that sits between your API code and your database — typically Redis or Memcached, sometimes an in-process LRU cache for hot, small datasets. It's where you cache expensive query results, computed aggregates, session data, and rate-limit counters. Two failure modes dominate here:

Cache stampede — when a popular cache key expires and hundreds of concurrent requests all miss simultaneously and hammer the database at once trying to regenerate it. The standard fix is "singleflight" (also called request coalescing): only one of those concurrent requests actually goes to the database; the rest wait on that one result and share it. Redis client libraries and frameworks increasingly build this in as a first-class option rather than something teams hand-roll.

Thundering herd via synchronized expiry — when many keys are set with the same TTL and expire at the same moment, causing a spike. Probabilistic early expiration (jittering the TTL slightly per key, or triggering an early background refresh probabilistically as a key approaches expiry) smooths this into a steady trickle of regenerations instead of a spike.

4. Database-level caching

Query result caching, materialized views, and read replicas fall in this bucket. This is the deepest layer and usually the last one teams optimize, because it requires understanding query patterns well. A materialized view that pre-aggregates a dashboard query overnight is a form of caching — it trades staleness (data as of last night) for speed (no expensive aggregation on every page load).

Framework-level caching is converging on the same idea

Across very different stacks, 2026's caching primitives are converging on the same shape: serve something immediately, refresh it lazily in the background. Next.js's use cache directive with explicit cacheLife profiles, RFC 5861's stale-while-revalidate at the HTTP layer, and singleflight-plus-jitter patterns in Redis are all the same idea implemented at different layers of the stack. If you understand the pattern once, you can apply it everywhere: don't force the user to wait on freshness; serve stale, and reconcile in the background.

Cache invalidation: the actual hard problem

There's an old joke that there are only two hard problems in computer science: cache invalidation, naming things, and off-by-one errors. It holds up because invalidation genuinely is where caching strategies fall apart.

A few patterns that work well in practice:

Content-hashed filenames for static assets. As noted above — if the URL changes whenever the content changes, there's nothing to invalidate. This eliminates an entire category of "why is the user still seeing the old CSS" bugs.

Tag-based purging instead of URL-based purging. Rather than tracking every URL that might contain a piece of data (a product's price might appear on a product page, a category page, a search results page, and a cart), tag each cached response with logical keys (product:1234, category:shoes) at cache-write time. When the product updates, purge by tag — the CDN or cache layer handles finding and evicting every cached response carrying that tag. Most modern CDNs and cache proxies (Cloudflare, Varnish, Fastly) support this natively.

Short TTLs plus event-driven purges, not long TTLs alone. A common mistake is picking a long TTL for a value that rarely changes, and no invalidation event for when it does. Better: a short-to-moderate default TTL as a safety net, backed by an explicit purge call fired from wherever the underlying data actually changes (a webhook, a database trigger, a queue consumer). That way staleness is bounded even if the purge event fails to fire for some reason.

Versioned cache keys for schema or logic changes. If the shape of a cached object changes (a new field added, a computation changed), bump a version segment in the cache key (user-profile:v3:123) rather than trying to migrate old cached entries in place. Old-version keys simply age out.

A note on caching and support/lead tooling

This matters beyond core app performance — it shows up directly in customer-facing tools too. A support bot or lead-qualifier widget embedded on a site is itself a caching decision: does it fetch the latest FAQ content on every page load, or cache a snapshot and refresh periodically? Cache it too aggressively and a widget answers customer questions with outdated pricing or a discontinued policy; don't cache it at all and every page load pays a network round trip for content that rarely changes. The same stale-while-revalidate instinct applies at a smaller scale — serve the cached answer instantly, refresh the underlying knowledge base in the background, and make sure there's an explicit invalidation path (a "republish" action, a webhook, a manual purge button) for when the underlying content changes and staleness actually matters.

Practical checklist

  • Hash static asset filenames; cache them for weeks; never think about invalidating them again
  • Keep HTML cache windows short; let CDNs revalidate frequently
  • Use stale-while-revalidate (and stale-if-error) wherever a few seconds or minutes of staleness is tolerable — which is more places than teams usually assume
  • Guard hot application-cache keys against stampede with request coalescing (singleflight)
  • Jitter TTLs on keys that expire in bulk to avoid synchronized thundering herds
  • Purge by logical tag, not by individually tracked URL
  • Bump versioned cache keys instead of trying to migrate cached shapes in place
  • Never cache anything transactional (price at checkout, live inventory, auth tokens) without a very short TTL and a hard invalidation path

Measuring whether your caching is actually working

Turning caching on isn't the finish line — measuring it is. Three numbers matter more than the rest:

Hit ratio. The percentage of requests served from cache versus forwarded to origin. A CDN dashboard will report this per zone or per path pattern; application caches (Redis) expose it through INFO stats (keyspace_hits vs keyspace_misses). A low hit ratio on a resource you expected to cache well usually means your cache key is too specific — for example, including a session ID or timestamp in the key when the underlying content doesn't actually vary per session.

Origin load reduction. The real business case for caching is protecting the origin and the database behind it. Track requests-per-second hitting the origin before and after a caching change goes live. This is what tells you whether a cache is actually load-bearing infrastructure or just a nice-to-have.

Staleness window. Harder to measure directly, but worth instrumenting for anything that matters (pricing, inventory): log the timestamp a cached value was generated alongside when it was served, and alert if the gap between "generated at" and "served at" exceeds your tolerance. This catches the failure mode where a purge silently stops firing and a cache "hits" forever on data from days ago.

Common mistakes that undo good caching

Caching per-user data under a shared key. A classic and painful bug: an API response includes user-specific data (their name, their cart) but the cache key doesn't include the user ID, so user B briefly sees user A's data. Any response that's personalized needs either a cache key that reflects the personalization, or Cache-Control: private so it's cached only in the browser and never at a shared CDN layer.

Caching error responses. A 500 or a malformed JSON payload cached for even 60 seconds can turn a transient blip into a sustained outage for every user who hits that cache key during the window. Explicitly set Cache-Control: no-store on error paths, or configure the cache layer to only store 2xx responses.

Forgetting cache warms after a deploy. If a deploy invalidates a large swath of cache keys at once (a schema version bump, a mass purge), the first wave of post-deploy traffic hits the origin at full force with no cache cushion. For high-traffic paths, a deliberate cache-warming step — pre-fetching the most popular keys right after deploy, before opening traffic back up — avoids a self-inflicted thundering herd.

Treating CDN cache and application cache as the same lever. Purging a CDN cache does nothing to a stale Redis key, and vice versa. Teams debugging "why is this still showing old data" often check only one layer and declare victory, only to have the bug resurface from the other layer minutes later. A useful habit: document, per data type, exactly which layers cache it and what purges each one.

Caching strategy isn't a single setting — it's a set of layered decisions, each with its own tradeoff between freshness and speed. The teams that get it right aren't the ones who cache the most aggressively; they're the ones who can say, for any given piece of data, exactly how stale it's allowed to get and exactly what event clears it when that line is crossed.

Sources:

Get new posts as they publish

No spam — just the next post, straight to your inbox.

Keep reading

Discussion