How to check last-modified header

Read the date with curl -s -D - -o /dev/null <url> | grep -i last-modified, then send it back as if-modified-since. A correct server answers 304 with zero bytes. Send a much older date and the same server must answer 200 with the full body.

Why check this

The date validator is the fallback when no ETag exists, and it is the one most often implemented by halves. Run the pair of conditional requests in regression whenever a static file server, a reverse proxy or a CDN rule changes. The failure it catches is a server that answers 304 to every conditional request regardless of the date, so clients keep a copy that was replaced hours ago and no amount of reloading fixes it. A single 304 in a test log is not evidence; the negative case is.

Prerequisites

Steps

  1. Step 1.

    Read the modification date from the resource. Copy it verbatim, including the day name and the GMT suffix.

    curl -s -D - -o /dev/null 'https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Cache-Control' | grep -i '^last-modified'
    
    last-modified: Fri, 11 Sep 2026 01:24:18 GMT

    The format is fixed by the specification. A date written any other way is ignored by a correct server, which then returns the whole body.

  2. Step 2.

    Send that exact date back and print the status with the number of bytes downloaded.

    curl -s -D - -o /dev/null -H 'if-modified-since: Fri, 11 Sep 2026 01:24:18 GMT' -w 'status=%{http_code} bytes=%{size_download}\n' 'https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Cache-Control' | grep -i -E 'HTTP/|^content-length|status='
    
    HTTP/2 304
    status=304 bytes=0

    A real 304 carries no body and no content-length. bytes=0 is the proof, and the absent content-length line is the second signal.

  3. Step 3.

    Run the negative case. A date long before the last change means the content did move on, so the server owes you the full body.

    curl -s -D - -o /dev/null -H 'if-modified-since: Mon, 01 Jan 2001 00:00:00 GMT' -w 'status=%{http_code} bytes=%{size_download}\n' 'https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Cache-Control' | grep -i -E 'HTTP/|^content-length|status='
    
    HTTP/2 200
    content-length: 248013
    status=200 bytes=248013

    Steps 2 and 3 together are the check. One answer of each kind means the server compares dates.

  4. Step 4.

    Read the same two headers from a second service to see how a validator arrives without any freshness directive.

    curl -s -D - -o /dev/null 'https://httpbin.org/cache' | grep -i -E '^last-modified|^etag|^cache-control'
    
    last-modified: Fri, 11 Sep 2026 19:12:48 GMT
    etag: 28465909695b40779c546e4a42e2e6ac

    No cache-control line came back, so nothing states a lifetime. The date is the only thing a cache can work from.

  5. Step 5.

    Run the negative case against that service. The date on the wire is from 2026, so a 2001 date must produce a 200.

    curl -s -D - -o /dev/null -H 'if-modified-since: Mon, 01 Jan 2001 00:00:00 GMT' -w 'status=%{http_code} bytes=%{size_download}\n' 'https://httpbin.org/cache' | grep -i -E 'HTTP/|status='
    
    HTTP/2 304
    status=304 bytes=0

    This is the broken shape. The server answers 304 because the request header is present, without comparing it to anything. A client obeying that answer keeps stale content forever.

How to read the result

| What you see | What it means | What to do | | --- | --- | --- | | Step 2 304, step 3 200 | Dates are compared correctly | Nothing. Record bytes=0 as the saving per repeat request. | | Step 2 200 with a full body | The conditional header is ignored or stripped | Check the proxy layer first. Many strip request headers they do not recognise. | | Step 3 304, as in step 5 | The server answers on header presence alone | Treat every 304 from this service as unverified and raise it as a correctness bug. | | A 304 with a content-length above zero | The status and the body disagree | Clients differ on what to do. Fix the server; a 304 has no body. | | No last-modified header | Only an ETag, or no validator at all | Fall back to the ETag check. With neither, every expiry is a full download. | | last-modified newer than date | The clock on the origin runs ahead | Caches may treat the entry as never fresh. Fix time sync before chasing cache bugs. |

Common mistakes

Sign: Every conditional request returns 304, including one carrying a date from 2001.Cause: The server checks whether if-modified-since arrived rather than what it holds. Step 5 shows a live service doing exactly this. Only the negative case exposes it, so a test that sends the correct date alone will pass against a broken server.
Sign: The conditional request works from curl and never from the browser.Cause: The browser sends if-modified-since only when it already stored the response, which needs a freshness directive or a heuristic. With no cache-control and a no-store meta tag in play, the browser never stores and never revalidates.
Sign: A file changes twice in the same second and clients keep the first version.Cause: HTTP dates have one-second resolution. Two writes inside one second produce the same last-modified value, so the comparison says nothing changed. An ETag derived from content is the fix.
Sign: A load balancer returns different last-modified values for the same file.Cause: The value comes from the file system, and a release that copies files to each node sets a new modification time per node. Clients then revalidate on every request because the date keeps moving.

What to check next

FAQ

How to test 304 not modified response?

Send the validator back and read two numbers: the status and the downloaded byte count. -w 'status=%{http_code} bytes=%{size_download}\n' prints both. 304 with bytes=0 is a real one; anything with a body is not.

How to test conditional requests with if-none-match?

The shape is the same, with the ETag instead of the date. Read etag, send it in if-none-match, then repeat with a wrong value and confirm a 200. The ETag procedure below covers the weak and strong forms.

Which wins when a client sends both headers?

The ETag. A server receiving if-none-match and if-modified-since evaluates the tag and ignores the date. Sending both is still worth doing, because caches that store no tag rely on the date.

Why is a 304 faster when the body is identical anyway?

Because the body never crosses the network. Step 3 moves 248013 bytes and step 2 moves none, for the same content. On a metered or high-latency link that difference is the whole reason the validator exists.

Verified

Verified by Maks Vernycurl 8.21.0

Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.

basic5 minpublished updated Maks Verny