How to Cache GET Endpoints Without Serving Stale Data
Every cache has an enemy it cannot fully see. The cache-control header can promise a browser that a response is fresh for sixty seconds, and a proxy can hold that same response for an hour, and a CDN edge can keep a copy in a rack in Frankfurt long after the origin has changed its mind. None of these layers talk to each other by default. A GET endpoint that returns a list of products, a profile record, a pricing table, or a status flag is, from the cache's point of view, just bytes with a timestamp. The work of caching a GET endpoint without serving stale data is mostly the work of making those timestamps honest and making the invalidation path fast enough that honesty arrives before a user notices.
The standard advice, "set a short TTL," is a blunt instrument that trades correctness for a number that feels safe. It also fails in the cases that matter most: a price change on a checkout page, a permissions flip on an admin console, a flag that turns a feature off after an incident. Those need invalidation, not a shorter timer. The endpoint needs a way to say, clearly, "this copy is no longer valid," and the caches in front of it need to hear that message through every layer.
The three caches a GET request actually passes through
A browser cache sits on the user's device. It is the cheapest and the most stubborn, because a hard refresh is the only reliable way to clear it and most users never perform one. A shared proxy or CDN edge sits between the user and the origin, often in dozens of locations, each with its own copy and its own expiry clock. The origin's application cache, whether Redis, Memcached, or an in-process map, is the one the engineering team actually controls. Serving stale data almost always means one of these three has a copy the others do not know about.
The browser is the hardest to reach. A response with a long max-age is a promise the origin cannot revoke, because the copy lives on a device the origin will never touch again. That is why invalidation has to start at the origin and move outward, and why the cache headers themselves have to be chosen with the invalidation strategy already in mind.
ETags and conditional requests as the honest fallback
An ETag is a fingerprint of a specific representation. The origin computes it, sends it in the response header, and the client stores it alongside the body. On the next request, the client sends If-None-Match with that fingerprint, and the origin answers 304 Not Modified if nothing has changed, or a fresh 200 with new bytes if it has. The value of this pattern is that the origin gets a chance to speak on every request, even when the client is holding a cached copy. A 304 is a few dozen bytes and saves the bandwidth of the full body without ever serving stale content, because the origin made the call.
ETags work best when they are derived from the actual data, not from a timestamp that changes on every write. A hash of the serialized response, or a version counter bumped inside the same transaction that changed the row, gives a fingerprint that is stable until the content genuinely changes. A timestamp-based ETag that ticks on every deploy will invalidate every cache on every release, which defeats the point. The rule is simple: the ETag must change exactly when the representation changes, and not one moment sooner.
Cache-Control directives that leave room for invalidation
The max-age directive tells a cache how long a response is fresh, and stale-while-revalidate tells it how long it may serve the old copy while fetching a new one in the background. For endpoints where a few seconds of staleness is fine, stale-while-revalidate is a gift: users get an instant response, and the edge refreshes behind them. For endpoints where staleness is a bug, the directive is a liability, because it explicitly permits serving the old bytes.
The s-maxage directive applies only to shared caches, which lets the origin give the CDN a different lifetime than the browser. A profile endpoint might use max-age=60 for the browser and s-maxage=300 for the edge, with stale-while-revalidate=30 on the edge only. That split is useful, but it also means the browser and the edge expire at different times, and the origin has to invalidate both. The private directive keeps a response out of shared caches entirely, which is the right call for anything user-specific. A response marked private can still be cached in the browser, which is often exactly what is wanted for a dashboard the user reloads repeatedly.
Purge by surrogate key, not by URL
Purging a single URL is easy to describe and painful to operate, because most content changes fan out across many URLs. A change to one product affects the product page, the category listing, the search results, and the recommendation rail. Purging each URL individually means the application has to know every URL that embeds the changed data, which is a coupling that rots quickly.
Surrogate keys solve this by tagging responses with one or more logical keys at the edge. The product response carries a tag like product-4471 and category-shoes. When the product changes, the application issues a single purge for product-4471, and every edge location drops every response bearing that tag. The application no longer needs to enumerate URLs; it only needs to know which logical entities a change touches. Fastly, Cloudflare, and several other CDNs support this pattern, and it is the single biggest operational improvement available for GET caching without staleness.
Invalidating the origin cache inside the write transaction
The origin cache is where most staleness bugs are born. A common sequence: the application writes to the database, returns success to the user, and then deletes the cache key. Between the commit and the delete, another request reads the old value from cache, writes it back, and the delete removes the fresh copy. The cache now holds stale data until the TTL expires, and the user who just saved a change sees the old value on the next page load.
The fix is to delete the cache key before the write commits, or to use a write-through pattern where the new value is written to the cache inside the same transaction that writes the database. Deleting before the commit means a concurrent reader might briefly miss the cache and hit the database, which is fine. The dangerous window is the one where a reader repopulates the cache with the pre-write value after the write has landed. Ordering the delete before the commit closes that window. A version counter stored alongside the cached value, checked against the database row on every read, closes it more expensively but more completely.
The stale-while-revalidate trap for permission and price data
Stale-while-revalidate is seductive because it makes dashboards feel instant. It is also the directive most likely to cause a real incident, because it explicitly tells the edge to serve old bytes while it refreshes. For a list of articles, that is fine. For a permissions check that decides whether a user can see a billing page, it is a data leak waiting for a traffic spike. The same is true for prices, inventory counts, and feature flags that gate access to a paid tier.
The safe rule is to reserve stale-while-revalidate for content where a few seconds of lag cannot change an outcome. Anything that gates access, money, or safety gets a short max-age, a purge on write, and no permission to serve stale bytes. The two categories should be separated at the routing layer, not by hoping the same policy works for both.
Versioned URLs and the cache-busting escape hatch
A query parameter that encodes a version, such as /api/products?version=42, turns every deploy or data migration into a new cache key. The old key expires on its own schedule, and the new key starts cold. For infrequent, application-wide changes, this is a clean escape hatch: the cache never serves a mix of old and new schema, because the two schemas live at different URLs. The cost is a cold cache on every version bump, which for a high-traffic endpoint can mean a thundering herd against the origin.
Versioning works well for static assets and for endpoints that change schema rarely. It works badly for per-entity changes, because bumping the global version to reflect one product edit throws away the entire cache. Use it as a targeted tool for migrations, not as the primary invalidation strategy.
A concrete invalidation sequence for a product listing
Consider a GET endpoint that returns a paginated product list. The response carries Cache-Control: public, max-age=30, s-maxage=120, stale-while-revalidate=15, plus a Surrogate-Key header of products and category-shoes. The origin caches the serialized page for 30 seconds in Redis, keyed by page and filter combination, with a version counter per category.
When a product in the shoes category is edited, the write path bumps the category version, deletes the affected Redis keys, and issues a CDN purge for the category-shoes surrogate key. The CDN drops every cached page bearing that tag across all edges. The next request misses the edge, hits the origin, which misses Redis, reads the database, and repopulates both. The browser still holds its copy for up to 30 seconds, but the ETag on that copy will trigger a 304 or a fresh 200 on the next conditional request, so the user sees the change within the browser's window.
That sequence has three properties worth naming. The browser window is the longest and the least controllable, so it is kept short. The edge window is longer because the purge is reliable. The origin window is shortest because it is the one the application can invalidate synchronously. Each layer's TTL reflects how quickly that layer can be told to forget.
Monitoring the gap between write and cache drop
The number that matters operationally is the time between a database commit and the moment every cache layer has dropped the old copy. That gap is the window in which a user can see stale data, and it is measurable. Emit a timestamp when a write commits, emit another when the CDN purge confirms, and alert when the difference exceeds a threshold the product can tolerate. Most teams discover their purge takes longer than expected under load, or that a subset of edge locations silently fails to purge, only after a customer complains.
A second number worth tracking is the ratio of purges to writes. A purge storm, where one write triggers hundreds of surrogate-key purges because a tag is attached too broadly, will crush the CDN's purge queue and leave edges inconsistent. Tagging at the finest useful granularity, and auditing which keys each response carries, keeps that ratio near one. The cache is only as honest as the invalidation path behind it, and that path deserves the same dashboards as the endpoint itself.

