Contents

A web application can cache at four levels — the browser, a CDN, the application and the database — and the closer the cache is to the user, the faster the response and the harder it is to invalidate. The real question is not where to cache but how to know data is stale: that is what TTLs, event-based invalidation and versioned keys are for.

Here are the caching levels, invalidation strategies and the mistakes that show users stale data.

At which levels can you cache?

You can cache in the user's browser, on a CDN or proxy in front of the server, inside the application and in the database itself. Each level takes load off the next, and a mature project usually uses several at once.

Level What is cached How it is invalidated Good for
Browser Static files, sometimes API responses Expiry, new file names JS, CSS, images, fonts
CDN and proxy Whole pages and files Expiry, purge by URL or tag Public pages, catalogues, articles
Application Query and computation results TTL, deleting keys on change Reference data, aggregates, heavy reports
Database Materialised views, aggregates Scheduled or event-driven refresh Analytics, summary tables

Personal data — carts, dashboards, customer-specific prices — must never be cached at shared levels such as a CDN: one user could see another's data.

How do you configure browser and CDN caching?

Browser and CDN caching is configured with HTTP headers: Cache-Control says how long and by whom a response may be stored, and ETag lets the client ask whether anything changed. For static files, the best approach is a long lifetime plus file names containing a content hash.

# Build files with a hash in the name: app.3f9a1c.js — keep for a year, never revalidate
Cache-Control: public, max-age=31536000, immutable

# Public page: the CDN keeps it 10 minutes, then serves stale while refreshing in the background
Cache-Control: public, s-maxage=600, stale-while-revalidate=60

# User dashboard: never store in shared caches
Cache-Control: private, no-store

When a file changes, so does the hash in its name, so the browser fetches the new version on its own — nothing needs purging.

A 3D rendering of cloud computing Photo: Unsplash

How do you invalidate a cache without serving stale data?

Invalidate a cache in one of three ways: when the TTL expires, on a data change event, or by changing the key version. In practice they are combined: an event removes the entry immediately, and a TTL is the safety net in case the event is lost.

Three invalidation strategies

  1. Time to live (TTL). Simple and reliable, but data may be stale until expiry. Fine when a few minutes of delay are acceptable.
  2. Event-based invalidation. When a record changes, the application deletes related keys. Data stays fresh, but you must remember every place that data is cached.
  3. Versioned keys. The key includes a version or last-modified time: product:42:v17. A new version means a new key, and the old one expires by itself.

Versioning is especially handy for composite data. For example, an article's HTML can be cached under a key containing its modification time: editing the article changes the time and therefore the key — no separate purge needed.

$key = sprintf('post:%d:html:%d', $post->id, $post->updated_at->getTimestamp());

$html = Cache::remember($key, now()->addWeek(), fn () => $renderer->render($post));

What is a cache stampede and how do you avoid it?

A cache stampede happens when a popular cache entry expires and hundreds of simultaneous requests hit the database for the same data at once. A database that ran comfortably behind the cache collapses under the sudden load.

How to protect against it:

  • a recompute lock: only one process computes the new value while others wait or get the old one;
  • serving the stale value during the refresh — like stale-while-revalidate on a CDN;
  • randomised TTLs so thousands of keys do not expire in the same second;
  • cache warming after deploys and purges — pre-filling the most popular entries.

What are the most common caching mistakes?

The most common mistakes are caching personal data in a shared cache, forgetting to invalidate on change and using the cache to hide slow queries that should have been fixed. A cache should speed up a fast system, not mask the problems of a slow one.

Typical mistakes:

  1. A page with personal data cached on a CDN — users see other people's carts.
  2. An admin change does not appear on the site for hours because invalidation was forgotten.
  3. The cache hides a query with no index — on a miss the page takes ten seconds. Indexes first, cache second; see PostgreSQL indexes.
  4. Keys with no prefix or version — impossible to purge a group of related entries.
  5. No hit-rate monitoring — nobody knows whether the cache works at all.

Redis is the most common application-level cache; we covered the settings to check in Redis in a web project.

In short

  • Cache at several levels: browser, CDN, application, database.
  • Static files get a long lifetime and hashed names; public pages get a CDN with stale-while-revalidate.
  • Invalidate on events, back it up with TTLs, and use versioned keys for composite data.
  • Guard against stampedes with locks, stale-while-revalidate and randomised TTLs.
  • Never cache personal data in shared caches, and never hide slow queries behind a cache.

FAQ

  • Should API responses be cached?

    Public, rarely changing ones, yes: reference data, catalogues, content. Responses with personal data are cached only on the client or in the application under a user-specific key.

  • How can we tell the cache is working?

    Watch the cache hit rate, response time and database load before and after. CDN providers show this in their dashboards; for the application cache, export it to your monitoring.

  • Why do users see the old site version after a deploy?

    Usually because build files are cached without a hash in their names or the HTML page is cached for too long. The fix is hashed static file names and a short lifetime for HTML.