HTTP Cache Validation with ETag and Last‑Modified

In Brief
Cache-Control tells a cache when a stored response is fresh. ETag and Last-Modified let it check a response that needs validation. The client returns an ETag through If-None-Match, or a modification time through If-Modified-Since. If the stored representation is still valid, the responder can return 304 Not Modified. When both conditions are present, If-None-Match takes precedence. Use an ETag when a timestamp is too coarse or unreliable.
A 304 Not Modified response is one of those things that looks obvious in a browser's network panel until someone looks over your shoulder and asks you to explain why it happened. There was a request and a response, but apparently nothing came back. An ETag looks like a hash, except when it doesn't. Last-Modified looks simpler, until two versions of a resource share the same second.
The missing piece in all of this is validation. A cache can keep a response, decide that it is too old to reuse without checking, then ask whether its stored representation is still good. That check is a conditional HTTP request. ETag and Last-Modified provide the values being checked.
Freshness and Validation are Different Jobs
HTTP caching has two related decisions:
- May this response be stored and reused?
- If it cannot be reused as fresh, is the stored response still valid?
The first is mainly a cache policy question. A response such as Cache-Control: public, max-age=60 can be treated as fresh for 60 seconds, subject to the rest of the request and response. During that period, a cache may be able to reuse it without contacting the next server at all.
The second decision comes later. Once the response is stale, or a policy requires validation before reuse, a validator gives the cache something useful to ask. The current HTTP caching specification separates freshness from validation for exactly this reason.
A validator does not set a lifetime. Adding an ETag does not mean "cache this for an hour", and adding Last-Modified does not force a browser to make a conditional request on every visit. Cache-Control, the cache's stored state, the request, and the type of cache still decide whether validation is needed.
That distinction also separates this mechanism from application and framework caches. The broader article on caching across React, Next.js, and serverless applications covers several places data can be reused. HTTP validators belong to the request and response layer.
The Normal Exchange
Suppose a server returns an article like this:
HTTP/1.1 200 OKDate: Wed, 05 Aug 2026 10:00:00 GMTContent-Type: text/plain; charset=utf-8Content-Length: 29Cache-Control: public, max-age=60ETag: "article-v7"Last-Modified: Wed, 05 Aug 2026 09:30:00 GMTValidator fixture version 7.The cache can store the response. For the next 60 seconds, max-age=60 supplies its explicit freshness lifetime. The two validators are kept as metadata alongside the body.
After that, the response needs validation, the cache can return the entity tag in If-None-Match:
GET /article HTTP/1.1Host: example.testIf-None-Match: "article-v7"If the selected representation still has a matching entity tag, the condition evaluates to false: there is no different representation to send. For this GET, the responder returns:
HTTP/1.1 304 Not ModifiedDate: Wed, 05 Aug 2026 10:01:01 GMTCache-Control: public, max-age=60ETag: "article-v7"Last-Modified: Wed, 05 Aug 2026 09:30:00 GMTThe cache updates relevant stored metadata and reuses the body it already has. If the entity tag has changed, the response is normally a full 200 OK with the new representation and its new validators.
What an ETag Actually Represents
An ETag is an opaque value selected by the server for a representation. Opaque matters. The client compares the value; it does not need to understand how it was made.
It might be a content hash. It might also be a database revision, a deployment identifier combined with a locale, a file signature, or another version that the service can reproduce consistently. The ETag definition and generation rules in RFC 9110 deliberately leave that choice to the implementation that understands the resource.
That makes "ETags are hashes" a tempting but inaccurate shortcut. Hashing the final bytes can produce a strong validator, but it may be unnecessarily expensive if the application already has a reliable revision number. Conversely, a cheap value is not useful if it changes on every request or stays fixed whilst the representation changes.
The right owner is usually the layer that knows the selected representation and can make the same decision across every origin node. For a static file, that may be the web server. For a CMS‑backed API, it may be the application using a content revision. For a generated page, it may need to combine content, template, locale, and variant versions. A CMS API's ETag is not automatically the correct ETag for the HTML page built from that API response.
Last‑Modified is Simpler, with a Limit
Last-Modified is the origin server's timestamp for when it believes the selected representation last changed. When that time is reliable, the implementation can be pleasantly dull. Static files already have modification times, and CMS entries often have a stored update time.
The client can return that value in If-Modified-Since:
GET /article HTTP/1.1Host: example.testIf-Modified-Since: Wed, 05 Aug 2026 09:30:00 GMTFor a GET or HEAD request, if the selected representation has not been modified after that time, the server responds with 304 Not Modified. If it has changed, the server returns the current response.
The awkward edge is precision. HTTP dates have one‑second resolution. A resource that can change twice within a second may therefore have two different bodies with the same apparent modification time. The specification treats a modification date as implicitly weak unless the comparison context can establish otherwise. The current Last-Modified rules also prohibit an origin from sending a future modification date relative to its response date.
Clock and deployment consistency matter too. If several origin nodes disagree about time, restore older file timestamps during a release, or derive the value from only one part of an aggregated page, the header stops telling a dependable story.
Use Last-Modified when there is a real, stable modification time and second‑level precision is enough. Do not invent a timestamp from the current request time. That guarantees churn rather than validation.
Strong and Weak ETags
An ETag is strong unless it carries the case‑sensitive W/ prefix:
ETag: "article-v7"ETag: W/"article-v7"A strong validator changes whenever the representation data observable in a successful GET changes. That supports byte‑sensitive operations, including range handling and lost‑update protection. Two strong entity tags match only when neither is weak and their opaque values match character for character.
A weak validator says the server considers the representations interchangeable for the comparison being made, even though their bytes might not be identical. A page could contain a harmlessly changing render timestamp whilst the service groups those versions under one weak ETag. The server should still change the weak tag when an older stored response is no longer an acceptable substitute.
For cache validation, If-None-Match uses the weak comparison function. That means W/"article-v7" and "article-v7" have matching opaque values for this check. Weak ETags are not suitable wherever exact representation equality is required. The strong and weak comparison rules are short, but this distinction is why the prefix cannot be treated as decoration.
Although cache revalidation normally concerns GET and HEAD, If-None-Match is not confined to those methods. When its condition evaluates to false for GET or HEAD, the result is 304 Not Modified. For another method, the response is 412 Precondition Failed. That makes the same header useful for requests such as "create this resource only if it does not exist", although that is a different job from validating a cached response. If-Modified-Since is only defined for GET and HEAD.
If you can guarantee byte‑level identity across responses and variants, use a strong ETag. If you only mean "these versions are good substitutes for cache reuse", mark it weak. A strong‑looking value with weak semantics is worse than an honest weak validator.
When Both Validators Arrive
Browsers and intermediary caches may send both validators. This helps older recipients that understand date validators but not entity tags. It does not mean the server gets to choose whichever answer is convenient.
For cache validation, If-None-Match takes precedence. A recipient must ignore If-Modified-Since when If-None-Match is present. RFC 9110 states that precondition directly in the If-Modified-Since definition, and RFC 9111 repeats it for caches.
This request is a useful test:
GET /article HTTP/1.1Host: example.testIf-None-Match: "article-v6"If-Modified-Since: Wed, 05 Aug 2026 09:30:00 GMTAssume the current ETag is "article-v7", but the Last-Modified value still matches the supplied time. The ETag condition does not match, so the server proceeds with the request and returns 200 OK. It must not use the matching date to return 304.
This ordering prevents a coarse or unreliable timestamp from overruling the more accurate entity tag. It is also a handy regression test for application code that evaluates conditional headers in the order they happen to appear.
A 304 is Not a Free Response
304 Not Modified confirms that the client's stored representation can still be used. It normally avoids transferring the body, which is the useful saving. It does not promise that no work happened.
The responder may still need to route the request, select a representation, read version metadata, query a store, or calculate a validator before it can compare the condition. A CDN might answer from its own cache without reaching the origin, or it might forward the request. The status alone does not tell you which layer worked or how much.
A 304 is also not a redirect to another URL. It sits in the 3xx status class, but there is no new location for the client to follow. It tells the client to reuse its stored result. Under the 304 response requirements, the response cannot contain content or trailers, although headers such as Date, ETag, Vary, Cache-Control, and Expires can update cached metadata.
That updated metadata matters. A cache can make the stored response fresh again without downloading the representation a second time.
Cache‑Control Still Owns the Freshness Policy
Validators work best when the storage and freshness policy is explicit.
Cache-Control: no-store tells caches not to store the response. With no stored response, there is normally nothing to validate later. Cache-Control: no-cache allows storage but requires successful validation before reuse. A positive max-age allows reuse whilst the response remains fresh. must-revalidate constrains reuse once it becomes stale.
Those directives answer different questions from ETag and Last-Modified. A response can have excellent validators and a policy that rarely needs them. It can also be stored with no useful validator, forcing a full transfer when it becomes stale.
Do not add every directive you have seen to make a response look safe. Decide whether the response is public or private, whether it may be stored, how stale it may become, and what should happen when it needs checking. Then add validators whose semantics the serving path can uphold.
Why CDNs and Reverse Proxies Complicate the Picture
Conditional requests can be evaluated by caches as well as the origin. That is useful, but it gives the request more than one place to surprise you.
Compression is a common example. Content codings are part of the representation data, so a strong ETag for a gzip representation has to differ from the strong ETag for an unencoded representation. After an intermediary transforms content, any validator it forwards still has to describe the representation now being served. Reusing the origin's strong tag for different bytes makes its claimed strong semantics false and can break later cache or range comparisons.
Variants create a similar problem. If a response changes with Accept-Encoding, language, or another request header, its Vary metadata and validator generation need to agree about which representation is being selected. Otherwise a cache can pair a validator with the wrong stored variant.
Several origin nodes can also produce needless misses. If each node salts or formats the same ETag differently, a request that moves between nodes sees a new version where none exists. The reverse problem is more serious: a shared tag that survives a genuine representation change can make stale content look valid.
There is no universal CDN fix here. Check the public response, the origin response where you are authorised to reach it, and the configuration that controls transformation and validator handling. Framework and platform defaults may be sensible, but they are still part of the system you are debugging.
Generated Pages, CMS Content, and Migrations
Generated content rarely has one obvious modification time. An article page may depend on the article, author, navigation, related content, image metadata, and template release. Which change counts as "last modified" is a product and implementation decision.
An ETag can represent that combined version without pretending it is a timestamp. The input might include CMS revision IDs, the template version, locale, and other fields that actually alter the selected response. Generate it where those dependencies are known, and make the result deterministic across builds and origin nodes.
This is separate from framework invalidation. A webhook can invalidate a Next.js cache tag, regenerate a route, or purge a CDN entry. Once a response is served again, HTTP validators govern whether a browser or intermediary can reuse its stored representation. The article on App Router cache tags and CMS publishing owns the framework side of that boundary.
During a migration, compare the validators and cache policy before and after the move. A one‑off ETag change after deployment is expected if the bytes or representation identity changed. An ETag that changes on every request is not. Check compression, Vary, clocks, multiple origins, route variants, and whether a CMS or reverse proxy is stripping the headers you thought the application returned.
If the visible problem is "I published it, but the page is still old", start with the controlled path in CMS content not updating in Next.js. Validators are one possible boundary, not a complete CMS publishing model.
Debugging Revalidation Without Guessing
Browser developer tools are useful for seeing the real request and response headers. Preserve the network log, inspect the exact request, and distinguish 304 from (memory cache) or (disk cache). A memory‑cache hit may make no network request at all. A 304 proves that a conditional request reached some responder.
Be careful with "Disable cache". It is useful for other performance work, but it changes the very behaviour you are trying to inspect. MDN's practical HTTP caching guide also notes that reloads, force reloads, history navigation, and developer tools can exercise different browser paths.
I usually take the browser out of the loop for a second pass. Capture a validator, then replay it explicitly:
curl --include http://127.0.0.1:8765/articlecurl --include \ --header 'If-None-Match: "article-v7"' \ http://127.0.0.1:8765/articlecurl --include \ --header 'If-Modified-Since: Wed, 05 Aug 2026 09:30:00 GMT' \ http://127.0.0.1:8765/articleUse the exact URL and representation variant each time. If compression is involved, keep Accept-Encoding consistent. Change one header at a time, then try the deliberate precedence case with both conditions. A deterministic 200 or 304 is much easier to reason about than repeatedly refreshing a tab.
Check these failure modes when the exchange still looks wrong:
- The ETag changes on every response even though the representation does not.
- Different origin nodes generate different validators for the same representation.
- A strong ETag is reused across compressed and uncompressed bytes.
Last-Modifiedis derived from an unreliable clock or cannot distinguish rapid changes.- A proxy, CDN, or framework strips or rewrites the validators.
Varydoes not describe the request headers that select the response variant.- A CMS API validator is copied onto a page with additional dependencies.
no-store, a long freshness lifetime, or another cache policy means the conditional request you expected is never needed.- Browser memory cache behaviour is mistaken for HTTP revalidation.
The first bad boundary is the one to fix. Clearing every cache may hide it for an afternoon, but it does not make the validator trustworthy.
Which Validator Should You Use?
Send both when you can generate both correctly. An ETag gives you a more precise, server‑defined identity and takes precedence during cache validation. Last-Modified is cheap and useful when the resource has a trustworthy modification time. They complement one another, but neither rescues a poor cache policy.
Use a strong ETag when byte‑level identity matters and you can keep the value consistent across every served variant. Use a weak ETag when semantic equivalence is enough for cache reuse. Use Last-Modified when seconds are precise enough, and your clocks and source timestamps are dependable.
If none of those statements is true, fix the version model first. A validator is a promise that the server can recognise the representation again. Guessing at one only makes a stale response harder to diagnose.
Wrapping Up
ETag and Last-Modified are small pieces of metadata with a very specific job. They let a client ask whether the response it already holds can still stand, without making freshness policy or application caching disappear.
Once you separate those responsibilities, the exchange is straightforward: store a response under an explicit policy, return its validator in a conditional request, then reuse the body after a 304 or replace it after a 200. The difficult part is not the four header names. It is keeping their meaning stable across applications, origins, proxies, CDNs, CMS publishing, and migrations.