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:

  1. Initial response — the origin serves the resource with a Last-Modified header. A cache (browser, CDN, or proxy) stores both the body and this timestamp alongside its freshness metadata.
  2. Conditional revalidation — once the stored response is stale (its max-age or s-maxage has elapsed, see how to calculate cache freshness lifetime), the cache does not discard the body. Instead it issues a conditional GET carrying If-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 Modified with 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 OK with a new body and an updated Last-Modified value.

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 responsesLast-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-Modified reports 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

Last-Modified / If-Modified-Since Validation Flow Sequence diagram showing an initial response carrying Last-Modified, a cache storing that timestamp, a subsequent conditional request carrying If-Modified-Since, and the origin branching to either a 304 Not Modified or a full 200 OK response depending on whether the resource changed. Client / Cache Origin Server GET /style.css 200 OK Last-Modified: Sun, 06 Jul 2026 09:00:00 GMT Cache-Control: max-age=3600 stores body + date — max-age expires; next request revalidates — GET /style.css If-Modified-Since: Sun, 06 Jul 2026 09:00:00 GMT changed since that date? No 304 Not Modified Yes 200 OK new body + date 304 resets the freshness clock on the existing stored body; 200 replaces the stored body and its Last-Modified value.

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.


max-age and Last-Modifiedmax-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-Modifiedno-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

  1. Open the Network tab with “Disable cache” unchecked.
  2. Reload the page after the resource’s max-age has elapsed.
  3. Click the request; confirm the Request Headers panel shows If-Modified-Since and the Response panel shows status 304.
  4. If the response instead shows 200 every time, either no validator was stored, or an intermediate cache is stripping If-Modified-Since on the way to origin.

Failure Modes and Gotchas

  1. 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.

  2. Clock skew between tiers — if the origin, CDN, and client disagree on the current time, If-Modified-Since comparisons can behave unpredictably: a client clock running fast can send a future-dated If-Modified-Since that the origin treats as always “newer,” producing spurious 304s even on genuinely updated content. See the dedicated walkthrough at handling clock skew in Last-Modified validation.

  3. Timestamp lost across a deploy — redeploying static assets by copying files (rather than preserving original mtimes) resets every file’s Last-Modified to the deploy time, even for files whose content did not change. This is harmless for correctness (clients simply revalidate and get 304, since content is identical — assuming a strong ETag isn’t also mismatched) but defeats heuristic freshness calculations that assume Last-Modified reflects genuine content age.

  4. Intermediate proxy strips or rewrites the header — some WAFs and legacy proxies strip Last-Modified from responses as a defensive measure against information disclosure (revealing internal file timestamps). Confirm the header survives every hop with curl -sI against each tier independently.

  5. 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.

  6. 304 responses accidentally carrying a body — a misconfigured proxy or application framework that emits both 304 status and a full response body violates RFC 9110 and confuses clients that expect an empty body on 304. Verify with curl -sI that Content-Length is absent or zero on every 304.

  7. Heuristic freshness masking a missing max-age — a response with Last-Modified but no explicit Cache-Control freshness 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.


Back to Cache Validation & Conditional Requests