All resources
Web Performance9 min read

HTTP caching and ETags without stale-data surprises

Learn how cache directives, validators, and conditional requests reduce latency while preserving the freshness your product promises.

A practical PingFlow guide for developers working at the boundary between systems.

At a glance

Key takeaways

  • Caching is a correctness policy
  • Use validators to avoid transferring unchanged data
  • Separate freshness from revalidation
In this guide

Caching is a correctness policy

HTTP caching can make a service dramatically faster, but a cached response is still a product decision. The cache may serve a representation after the origin has changed, so you must decide how much staleness each resource can tolerate. Public documentation, an avatar image, a billing balance, and a webhook delivery status should not share the same freshness policy. Start by classifying the data as immutable, short-lived, revalidatable, private, or never cacheable.

Write the policy in response headers instead of relying on a CDN's undocumented defaults. Cache-Control communicates whether a response is public or private, how long it may be fresh, and whether it may be stored. A browser and a shared proxy have different risks. A response that includes user-specific data must not be stored in a shared cache unless the cache key and authorization model are explicitly designed for it.

Use validators to avoid transferring unchanged data

An ETag is a validator for a representation. The server sends an opaque value with the response, and the client later sends If-None-Match. If the representation has not changed, the server returns 304 Not Modified without sending the body. This saves bandwidth while allowing the origin to decide freshness. The tag can be a content hash, a version number, or a database change token, as long as the value changes whenever the served representation changes.

Do not calculate an ETag from data that is not included in the response. If authorization, locale, compression, or feature flags change the representation, the cache key and validator must account for those dimensions. Weak validators can represent semantically equivalent responses even when bytes differ, while strong validators require byte-for-byte identity. Choose the strength that matches how clients use conditional requests and range responses.

Separate freshness from revalidation

A response can be fresh for a short period and then revalidated with the origin. max-age controls how long a cache may use the response without checking. stale-while-revalidate allows a cache to serve a stale response while it fetches an update in the background. stale-if-error can preserve availability during a temporary origin failure, but it must not hide a critical state change forever. Set these extensions only when the product can tolerate their behavior.

The client may also send request directives such as no-cache, which usually means revalidate rather than never store. no-store is stronger and asks that the response not be stored. Do not use no-cache as a synonym for no-store in documentation or code. When debugging a freshness issue, capture both request and response headers, the cache status, and the age of the stored object. A browser reload does not always mean an origin request.

Avoid cache key collisions

A cache key usually includes the URL, but the response may vary by headers such as Accept, Accept-Encoding, Authorization, or a locale preference. Vary tells a shared cache which request headers affect the representation. Use it deliberately because high-cardinality variation can destroy hit rates. Never let a shared cache ignore an authorization boundary. A public and private response must not be stored under the same key merely because the path is identical.

Query parameters are part of many cache keys, but not every proxy treats them the same way. Normalize parameters only when order and duplicates are semantically irrelevant. If a response changes based on a cookie or feature flag, make the variation explicit or move the personalized part to a private request. Test the actual CDN and browser path rather than assuming the origin's framework configuration controls every intermediary.

Invalidate intentionally when writes happen

Time-based expiration is simple, but user-facing products often need faster visibility after a write. A successful update can purge a known public cache key, publish an invalidation event, or return a new representation with a fresh validator. Avoid trying to enumerate every derived cache key synchronously in a request handler. A small invalidation queue with retries is safer when one entity appears in many views.

Do not invalidate a cache before the authoritative write commits. If the database transaction fails after the purge, the next read may fetch the old value and a client may interpret that as a lost update. If a cache can serve stale data during a short window, document it. For sensitive state such as subscription access, check current authorization on the server instead of trusting a cached UI representation.

Measure cache behavior as a user experience

Track hit rate, miss rate, revalidation rate, origin latency, response age, eviction count, and bytes saved. A high hit rate can still be a failure if the cache serves stale or unauthorized data. Include cache status in internal diagnostics, not necessarily in a public response. Compare first-load and repeat-load latency from representative networks because a cache that helps a data center client may not help a mobile user.

Watch for cache stampedes when a popular object expires. Request coalescing, jittered expiration, background refresh, and a small stale-while-revalidate window can prevent thousands of clients from rebuilding the same response. Do not add a long stale window just to hide an overloaded origin. Fix the origin or capacity plan when freshness is part of the product promise.

Test headers across real clients

Build tests for public, private, authenticated, error, redirect, compressed, and conditional responses. Verify that 304 responses omit a body and preserve the headers a client needs. Test a changed ETag, an unchanged ETag, a different language, and a request with a revoked authorization context. Inspect a real browser, an API client, and the production CDN because each may implement directives differently.

Caching is successful when the product is faster without becoming less trustworthy. Define the freshness promise, choose validators, make variation explicit, invalidate after durable writes, and observe what intermediaries actually do. When a user reports stale data, the answer should come from headers and cache logs rather than a guess about which layer remembered the old response.

Prove the cache contract

Test cache behavior through the real CDN or proxy, not only a local development server. Record the response headers for a fresh request, a cache hit, a conditional request, and an explicitly invalidated resource. Include Vary keys, authorization behavior, stale responses, and the purge path in the review. A cache is correct when it saves work without serving one user's representation to another or leaving an important update invisible beyond its stated freshness window.

Implementation example

Choose cache directives from the resource's sensitivity and freshness promise. Use validators for revalidation, explicit invalidation for important changes, and a private response for user-specific data. Keep cache keys independent of untrusted headers unless the variation is deliberate and bounded.

http
Cache-Control: public, max-age=60, stale-while-revalidate=300
ETag: "resource-v7"
Vary: Accept-Encoding

Verify and troubleshoot

Capture a fresh request, a cache hit, a conditional request with If-None-Match, an invalidated resource, and a request from a different user. Inspect Age, ETag, Vary, Cache-Control, status, and response body. A 304 with a stale client representation can be correct only when the validator and invalidation contract are correct.

Operations and recovery

Track hit ratio, origin latency, purge failures, stale age, and cache-key cardinality. Keep a purge or versioned-key rollback path for urgent corrections. If private data is ever observed in a shared cache, revoke affected sessions or responses, purge the key space, and investigate proxy configuration before re-enabling storage.

References and further reading

Use RFC 9111 for HTTP caching, RFC 9110 validators and conditional requests, and the CDN's cache-key and purge documentation. State which layer owns freshness and which layer owns invalidation.

Keep exploring