Last-Modified and Date-Based Validation
TL;DR: Last-Modified timestamps a response with the origin’s best knowledge of when the underlying resource last changed. Clients echo that timestamp back on the next request via If-Modified-Since, and the origin returns 304 Not Modified if nothing changed since — saving bandwidth without re-sending the body. It is a weak, second-granularity validator; treat it as a cheap fallback, not a replacement for content-based validation.
HTTP/1.1 200 OK
Cache-Control: public, max-age=3600
Last-Modified: Sun, 06 Jul 2026 09:00:00 GMT
Content-Type: text/css
Mechanism and RFC Alignment
Last-Modified is a validator field defined in RFC 9110 §8.8.2, the HTTP Semantics specification. It carries the origin server’s best approximation of the timestamp at which the response’s representation was last changed. A server generating this header should derive it from actual data provenance — a file’s modification time, a database row’s updated_at column, or a build timestamp — not from the current time, which would make the header useless for revalidation.
The mechanism operates in two round trips:
- Initial response — the origin serves the resource with a
Last-Modifiedheader. A cache (browser, CDN, or proxy) stores both the body and this timestamp alongside its freshness metadata. - Conditional revalidation — once the stored response is stale (its
max-ageors-maxagehas elapsed, see how to calculate cache freshness lifetime), the cache does not discard the body. Instead it issues a conditional GET carryingIf-Modified-Since: <the stored Last-Modified value>. The origin compares this to its current resource timestamp:- If the resource has not changed since that timestamp, the origin returns
304 Not Modifiedwith no body, and the cache resets its freshness clock against the existing stored representation. - If the resource has changed, the origin returns a full
200 OKwith a new body and an updatedLast-Modifiedvalue.
- If the resource has not changed since that timestamp, the origin returns
If-Modified-Since is defined in RFC 9110 §13.1.3. It is a request precondition: the server evaluates it only against GET or HEAD requests, and only when the server can determine a last-modification time for the requested representation. This entire exchange belongs to the broader family of conditional requests and 304 responses — Last-Modified/If-Modified-Since is the date-based half of that mechanism, distinct from the content-hash-based half covered under ETag generation and validation strategies.
HTTP-date format (RFC 9110 §5.6.7)
Both Last-Modified and If-Modified-Since carry their timestamp as an HTTP-date, formally specified in RFC 9110 §5.6.7. The preferred and only format servers should generate is IMF-fixdate:
Sun, 06 Jul 2026 09:00:00 GMT
This is a fixed-width, English-locale, GMT-only format: three-letter day-of-week, day, three-letter month, four-digit year, HH:MM:SS, and the literal GMT (never an offset). RFC 9110 permits recipients to also parse two obsolete formats — RFC 850 dates and asctime-style dates — for backward compatibility, but a conforming server must never generate those forms; it must always emit IMF-fixdate. Every recipient — Nginx, Apache, Cloudflare, browsers — parses this value into an internal timestamp and truncates it to whole seconds, which is the source of the granularity limitation covered next.
The one-second granularity limitation
HTTP-date has no fractional-second component. This is a hard grammar limitation, not an implementation choice: Sun, 06 Jul 2026 09:00:00 GMT cannot represent 09:00:00.250 or 09:00:00.750 — both collapse to the same value. If an origin regenerates a resource’s content twice within the same wall-clock second (a common occurrence in CI pipelines that write multiple files in a tight loop, or in database rows updated in rapid succession by two writes), the resulting Last-Modified timestamp is identical for both versions.
The practical consequence: a client holding the first version sends If-Modified-Since matching that timestamp, and the origin — seeing an equal timestamp rather than an older one — returns 304 Not Modified, even though the body has genuinely changed. This is the core reason Last-Modified is classified as a weak validator: it establishes an ordering of time, not an identity of content, and time resolution is coarser than many legitimate update frequencies.
Why Last-Modified is a weak validator
RFC 9110 §8.8.4 sets out explicit rules for choosing between validators, and its guidance is unambiguous: prefer strong validation using ETag whenever byte-for-byte equivalence matters, and treat Last-Modified as a supplementary, lower-confidence signal. Three structural weaknesses explain why:
- Granularity — as above, sub-second changes are invisible to a date-based validator.
- Clock dependency — the comparison assumes the timestamp source (filesystem mtime, database column, application clock) is monotonic and accurate. Clock skew between origin, CDN, and client — covered in depth in handling clock skew in Last-Modified validation — can produce timestamps that appear to move backward or into the future, breaking the ordering assumption the whole mechanism depends on.
- Semantic mismatch — a resource can be rewritten with byte-identical content but a newer modification time (a redeploy that touches every file’s mtime without changing any bytes).
Last-Modifiedreports this as a change; a content hash would not. The inverse also happens: a resource can change content while some deployment pipeline preserves an old mtime, masking a real update.
ETag avoids all three problems by hashing or fingerprinting the actual representation, at the cost of extra computation on the origin. See ETag vs Last-Modified: which validator to use for the decision framework, and strong vs weak ETags explained for how ETag itself models the strong/weak distinction that Last-Modified cannot express at all — every Last-Modified comparison is implicitly weak.
Heuristic freshness derived from Last-Modified age
Beyond validation, Last-Modified plays a second role defined in RFC 9111 §4.2.2: when a response is cacheable but carries no explicit freshness information (no max-age, no s-maxage, no Expires), a cache is permitted to compute a heuristic freshness lifetime instead of treating the response as immediately stale. The recommended heuristic is:
heuristic_freshness_lifetime = (Date − Last-Modified) × 0.10
That is, 10% of the age of the resource at the time it was served. A resource last modified 30 days ago and served today receives roughly 3 days of heuristic freshness. RFC 9111 explicitly warns implementers to attach a warning when serving heuristically fresh content older than 24 hours (historically the Warning: 113 header; RFC 9111 removed the Warning field from normative use but preserved the underlying caution). This behavior is implementation-defined — different caches use different percentages, some disable heuristic caching for particular content types, and some CDNs ignore it entirely and treat missing freshness information as max-age=0. Never design a caching strategy around heuristic freshness; always set explicit directives. See how to calculate cache freshness lifetime for the full precedence algorithm this heuristic sits at the bottom of.
Scope and Precedence
Last-Modified-based validation interacts with every tier of the cache hierarchy, but its authority is always subordinate to explicit freshness directives:
| Priority | Mechanism | Governs |
|---|---|---|
| 1 (highest) | no-store / private |
Prohibits storage entirely — no validator matters if nothing is stored |
| 2 | s-maxage / max-age freshness |
Determines whether revalidation is triggered at all |
| 3 | ETag / If-None-Match |
Preferred validator once revalidation is triggered |
| 4 | Last-Modified / If-Modified-Since |
Fallback validator, used alone or alongside ETag |
| 5 (lowest) | Heuristic freshness from Last-Modified age |
Only applies when no explicit freshness directive exists |
A response can carry Last-Modified without any Cache-Control directive at all — in that case it is still cacheable by default under RFC 9111’s heuristic rules, and Last-Modified does double duty as both the heuristic freshness input and the eventual revalidation key. This is fragile behavior; production systems should always pair Last-Modified with an explicit Cache-Control: max-age or s-maxage so the freshness lifetime is deterministic rather than derived.
When both ETag and Last-Modified are present, RFC 9111 §4.3.1 requires a revalidating client to send both If-None-Match and If-Modified-Since if it holds both values. The origin must then evaluate If-None-Match first; If-Modified-Since is only consulted as a fallback on servers that do not understand entity tags, or advisory when both are sent. This means a correctly implemented origin with both validators is immune to the granularity and clock-skew weaknesses of Last-Modified alone.
Architecture: Last-Modified → If-Modified-Since → 304/200 Flow
Implementation Patterns
Pattern 1: Static asset served with mtime-derived Last-Modified
HTTP/1.1 200 OK
Cache-Control: public, max-age=86400
Last-Modified: Fri, 03 Jul 2026 14:22:10 GMT
Content-Type: application/javascript
Content-Length: 48213
Web servers derive Last-Modified directly from the filesystem mtime. This is essentially free — no computation beyond a stat() call — which is why it remains the default validator for static file serving even where ETag would be stronger.
Pattern 2: Conditional request receiving 304
GET /app.js HTTP/1.1
Host: example.com
If-Modified-Since: Fri, 03 Jul 2026 14:22:10 GMT
HTTP/1.1 304 Not Modified
Cache-Control: public, max-age=86400
Last-Modified: Fri, 03 Jul 2026 14:22:10 GMT
The 304 response carries no body. It repeats Cache-Control and Last-Modified so the cache can reset its freshness clock without re-fetching the payload — this is the entire bandwidth saving this mechanism exists to provide.
Pattern 3: API response combining Last-Modified with ETag
HTTP/1.1 200 OK
Cache-Control: private, max-age=60
Last-Modified: Sun, 06 Jul 2026 08:55:00 GMT
ETag: "a1b2c3d4-order-88213"
Content-Type: application/json
For dynamic resources with sub-second update frequency, pair Last-Modified with ETag and let the origin prioritize If-None-Match over If-Modified-Since. Last-Modified here is advisory metadata for clients and debugging tools; ETag carries the actual revalidation authority.
Pattern 4: Authenticated endpoint — validator is irrelevant
HTTP/1.1 200 OK
Cache-Control: private, no-store
A response marked no-store is never stored by any shared or private cache, so no validator is ever consulted — emitting Last-Modified on such a response is harmless but pointless.
Pattern 5: CDN split — origin validates, edge enforces its own TTL
HTTP/1.1 200 OK
Cache-Control: public, s-maxage=3600, max-age=300
Last-Modified: Sun, 06 Jul 2026 07:00:00 GMT
The CDN edge honors s-maxage for its own storage and only issues an If-Modified-Since request to the origin once its 3600-second window elapses. Browsers, meanwhile, revalidate against the CDN every 300 seconds using their own conditional request — the CDN may answer that revalidation directly from edge storage without contacting the origin at all, provided its own copy is still within s-maxage.
Server and CDN Configuration
Nginx
Nginx’s static file module automatically emits Last-Modified from the file’s mtime and evaluates If-Modified-Since for you — no directive is required to enable the base behavior. The one thing worth tuning explicitly is comparison strictness:
location /assets/ {
root /var/www/example;
# exact = only equal timestamps produce 304 (strict RFC behavior)
# before = any client timestamp >= file mtime produces 304 (default, lenient)
# off = ignore If-Modified-Since entirely, always serve 200
if_modified_since exact;
add_header Cache-Control "public, max-age=86400";
}
before is Nginx’s default and tolerates client clocks that are slightly ahead of the origin; exact enforces a literal timestamp match and is more correct under RFC 9110 but more sensitive to clock skew (see the companion page on handling clock skew).
Apache
# Last-Modified/If-Modified-Since handling is built into mod_core;
# no directive needed to enable it for static files.
# Optionally strip Last-Modified when serving generated content
# where the filesystem mtime is meaningless (e.g. a templating cache dir):
Header unset Last-Modified
# Set an explicit freshness lifetime alongside the validator:
ExpiresActive On
ExpiresDefault "access plus 1 day"
Apache’s mod_headers and mod_expires operate independently of the built-in conditional-request handling in mod_core — disabling or overriding Last-Modified does not disable If-Modified-Since support elsewhere in the stack, so audit both if you need consistent behavior.
Cloudflare
Cloudflare’s edge evaluates conditional requests on the client side using its own cached copy whenever that copy is within TTL, and only forwards If-Modified-Since to the origin when the edge copy itself is stale and needs revalidation:
# Client → Cloudflare edge (edge copy still fresh)
GET /style.css HTTP/1.1
If-Modified-Since: Sun, 06 Jul 2026 09:00:00 GMT
# Cloudflare → client (answered from edge, no origin round trip)
HTTP/1.1 304 Not Modified
CF-Cache-Status: REVALIDATED
Use a Cloudflare Cache Rule or Transform Rule to guarantee Last-Modified survives to the edge (some origin configurations strip it for dynamically generated pages):
curl -X POST "https://api.cloudflare.com/client/v4/zones/$ZONE_ID/rulesets" \
-H "Authorization: Bearer $CF_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"phase": "http_response_headers_transform",
"rules": [{
"expression": "http.response.headers[\"content-type\"][0] contains \"text/css\"",
"action": "rewrite",
"action_parameters": {
"headers": {
"Cache-Control": {"operation": "set", "value": "public, max-age=86400"}
}
}
}]
}'
Verify the edge is actually revalidating rather than fetching fresh copies every time by checking CF-Cache-Status: REVALIDATED versus MISS on repeat requests, per the interpreting X-Cache and CF-Cache-Status headers reference.
Interaction with Related Directives
max-age and Last-Modified — max-age (and s-maxage for shared caches) determines when revalidation is triggered; Last-Modified determines how that revalidation is carried out once triggered. A response with a long max-age but no Last-Modified and no ETag cannot be revalidated at all once stale — the cache must discard it and issue a full unconditional request. Always pair an explicit freshness lifetime with at least one validator.
no-cache and Last-Modified — no-cache forces revalidation on every request regardless of remaining freshness, but still permits storage. This makes Last-Modified-based conditional requests fire constantly rather than only after expiry — a reasonable choice for content that must always be checked but rarely actually changes, since the bandwidth savings of 304 still apply even when the round trip itself cannot be skipped.
stale-while-revalidate and Last-Modified — see serving stale content with stale-while-revalidate. When a cache serves a stale copy while revalidating in the background, that background revalidation request is exactly the If-Modified-Since exchange described here. A 304 response confirms the stale copy that was already served to the user was, in fact, still correct.
ETag and Last-Modified together — when both validators are present, per RFC 9111 §4.3.1 a client must send both If-None-Match and If-Modified-Since, and the origin must give If-None-Match precedence. See ETag generation and validation strategies for how to generate the stronger validator alongside this one rather than instead of it.
Verification Workflow
Step 1 — Confirm Last-Modified is emitted and well-formed
curl -sI https://example.com/style.css | grep -i last-modified
Expected output is a single IMF-fixdate line: Last-Modified: Sun, 06 Jul 2026 09:00:00 GMT. If the header is missing, the resource cannot be conditionally revalidated by date at all.
Step 2 — Confirm a conditional request returns 304
LM=$(curl -sI https://example.com/style.css | grep -i '^last-modified' | sed 's/^[Ll]ast-[Mm]odified: //' | tr -d '\r')
curl -sI -H "If-Modified-Since: $LM" https://example.com/style.css
Expected: HTTP/1.1 304 Not Modified with no body and no Content-Length (or Content-Length: 0).
Step 3 — Confirm a stale conditional request returns 200
curl -sI -H "If-Modified-Since: Mon, 01 Jan 2000 00:00:00 GMT" https://example.com/style.css
An ancient date should always be “older” than the resource, forcing 200 OK with a full body and a fresh Last-Modified value — this validates that the origin is actually comparing dates rather than unconditionally returning 304.
Step 4 — Confirm sub-second updates are not silently masked
# Trigger two rapid updates to the same resource within one second, then:
curl -sI https://example.com/api/resource/88213 | grep -i last-modified
If your update pipeline can produce two distinct versions with an identical Last-Modified value, this is direct evidence you need a stronger validator (ETag) for that resource — see strong vs weak ETags explained.
Step 5 — Inspect the exchange in browser DevTools
- Open the Network tab with “Disable cache” unchecked.
- Reload the page after the resource’s
max-agehas elapsed. - Click the request; confirm the Request Headers panel shows
If-Modified-Sinceand the Response panel shows status304. - If the response instead shows
200every time, either no validator was stored, or an intermediate cache is strippingIf-Modified-Sinceon the way to origin.
Failure Modes and Gotchas
-
Sub-second regeneration collisions — as covered above, two content versions produced within the same wall-clock second are indistinguishable to
Last-Modified. High-frequency build pipelines and database-backed APIs are the most common victims. -
Clock skew between tiers — if the origin, CDN, and client disagree on the current time,
If-Modified-Sincecomparisons can behave unpredictably: a client clock running fast can send a future-datedIf-Modified-Sincethat the origin treats as always “newer,” producing spurious304s even on genuinely updated content. See the dedicated walkthrough at handling clock skew in Last-Modified validation. -
Timestamp lost across a deploy — redeploying static assets by copying files (rather than preserving original mtimes) resets every file’s
Last-Modifiedto the deploy time, even for files whose content did not change. This is harmless for correctness (clients simply revalidate and get304, since content is identical — assuming a strongETagisn’t also mismatched) but defeats heuristic freshness calculations that assumeLast-Modifiedreflects genuine content age. -
Intermediate proxy strips or rewrites the header — some WAFs and legacy proxies strip
Last-Modifiedfrom responses as a defensive measure against information disclosure (revealing internal file timestamps). Confirm the header survives every hop withcurl -sIagainst each tier independently. -
Non-IMF-fixdate values break parsing — a server that emits a nonstandard date format (a Unix epoch integer, a localized date string, an RFC 850 date sent by mistake in a code path that should generate IMF-fixdate) may be parsed inconsistently or rejected outright by strict clients. Always generate dates using your platform’s HTTP-date formatter, never hand-rolled string formatting.
-
304 responses accidentally carrying a body — a misconfigured proxy or application framework that emits both
304status and a full response body violates RFC 9110 and confuses clients that expect an empty body on304. Verify withcurl -sIthatContent-Lengthis absent or zero on every304. -
Heuristic freshness masking a missing max-age — a response with
Last-Modifiedbut no explicitCache-Controlfreshness directive appears to “work” because caches apply the RFC 9111 §4.2.2 heuristic, but the resulting freshness lifetime is entirely implementation-defined and varies between browsers, CDNs, and proxies. Never treat apparent caching behavior in one browser as proof the response is correctly configured.
Frequently Asked Questions
Does Last-Modified have to be an HTTP-date, or can I use any string?
It must be a valid HTTP-date as defined in RFC 9110 §5.6.7 — the IMF-fixdate format, such as Sun, 06 Jul 2026 09:00:00 GMT. Caches and browsers parse this value to build the If-Modified-Since request; a malformed or non-conformant date is either ignored or treated as an invalid validator, disabling conditional revalidation entirely.
Why does If-Modified-Since only have one-second precision?
HTTP-date values are truncated to whole seconds by definition — there is no sub-second field in the IMF-fixdate grammar. If a resource is regenerated more than once within the same second, its Last-Modified timestamp is identical across versions, and a conditional request cannot distinguish them. The server will incorrectly return 304 Not Modified for a body that actually changed.
What happens if a client sends If-Modified-Since but the resource has no Last-Modified header?
The origin server has nothing to compare the date against, so it must treat the request as unconditional and return a full 200 OK response. Some caches synthesize a Last-Modified value from the first time they stored the object, but this is a local heuristic, not something the origin can rely on for correctness.
Should I use Last-Modified or ETag as my validator?
RFC 9110 §8.8.4 recommends sending both when possible, but treating ETag as authoritative. Last-Modified is coarser (one-second granularity) and semantically weaker because it only reflects time, not content identity. Use it as a cheap, low-overhead validator for static files and fall back to ETag wherever byte-for-byte accuracy matters, such as APIs or frequently regenerated content.
Can heuristic freshness apply to a response even if it has no explicit max-age?
Yes. RFC 9111 §4.2.2 permits a cache to assign a heuristic freshness lifetime — commonly 10% of the age since Last-Modified — to any cacheable response lacking explicit freshness information. This is implementation-defined behavior and should never be relied upon in production; always set an explicit max-age or s-maxage instead.
Related
- Conditional Requests and 304 Responses — the general mechanics of conditional revalidation, of which date-based validation is one half.
- ETag Generation and Validation Strategies — the stronger, content-hash-based validator to pair with or replace
Last-Modified. - How to Calculate Cache Freshness Lifetime — the full RFC 9111 precedence algorithm that heuristic freshness sits at the bottom of.
- Handling Clock Skew in Last-Modified Validation — a focused walkthrough of what breaks when origin, CDN, and client clocks disagree.