Last-Modified header explained (tested)

Last-Modified is the server saying "this resource last changed at this time". It does two jobs: it lets clients revalidate cheaply with If-Modified-Since (a 304 instead of the full body), and, when there is no Cache-Control or Expires, it lets browsers guess a freshness lifetime on their own. The second job surprises people. We measured both in Chromium 153 and Express 5.

1. Format

Last-Modified: Tue, 22 Sep 2026 10:00:00 GMT

From the RFC, not measured: the value must be an HTTP-date in IMF-fixdate form (day name, day, month name, year, time, literally GMT), with one-second resolution. A server should not send a date later than its own Date header. In JavaScript, new Date(ms).toUTCString() produces the right format; in Node, fs.stat().mtime is the usual source.

2. Heuristic freshness: Last-Modified without Cache-Control (measured)

A Node.js server sent pages with a Last-Modified header and no Cache-Control or Expires. Chromium 153 navigated to each page, away, and back; then waited 8 seconds and navigated back again. The server logged every request.

Last-ModifiedRevisit right awayRevisit after 8 s
10 days oldserved from cache, no requestserved from cache, no request
1 minute oldserved from cache, no requestrequest with If-Modified-Since, got 304
none (no validators at all)request, full 200request, full 200

This matches the common heuristic from RFC 9111 (from the RFC, not measured as an exact number): a cache may treat the response as fresh for about 10% of the time since Last-Modified. Ten days old gives roughly a day of freshness; one minute old gives roughly six seconds. That is why a file you deployed weeks ago, served with no Cache-Control, can keep showing the old version after you update it: the browser does not ask.

Takeaway: if you send Last-Modified, also send an explicit Cache-Control. no-cache (or max-age=0) forces a check every time; a real max-age sets the lifetime you actually want. See no-cache vs no-store.

3. Reload sends If-Modified-Since (measured)

On page.reload() of the 10-day-old page, Chromium sent If-Modified-Since with the exact Last-Modified string it had stored, even though the cached copy was still heuristically fresh. Our server answered 304 with no body. The page itself saw status 200: the browser swaps the 304 for the cached response. To see the 304, look in DevTools Network or at the server log. The request side of this is covered in If-Modified-Since explained.

4. Express 5 defaults (measured)

express.static on a file with mtime 2026-09-22 10:00:00 UTC:

$ curl -sI http://127.0.0.1:8984/a.txt
HTTP/1.1 200 OK
Cache-Control: public, max-age=0
Last-Modified: Tue, 22 Sep 2026 10:00:00 GMT
ETag: W/"6-1a0c88ed900"
RequestStatus
If-Modified-Since equal to Last-Modified304
If-Modified-Since later than Last-Modified304
If-Modified-Since earlier than Last-Modified200
If-Modified-Since: yesterday (not a date)200
Later If-Modified-Since + non-matching If-None-Match200 (ETag wins)
After touch a.txt, old If-Modified-Since200, new Last-Modified

Express already sends max-age=0, so the heuristic in section 2 does not kick in for its static files; every use revalidates. Options:

Note that Express's static ETag (W/"size-mtime") is also built from the mtime, so on a static file the two validators carry nearly the same information.

5. Last-Modified vs ETag

Last-ModifiedETag
Request headerIf-Modified-SinceIf-None-Match
Resolution1 secondany change the server encodes
Heuristic freshness without Cache-Controlyes (measured above)no (from the RFC, not measured)
Both sentIf-None-Match is evaluated and If-Modified-Since ignored (RFC 9110; matched in Express above)

From the RFC, not measured: two edits in the same second, or a redeploy that resets mtimes on unchanged files, break Last-Modified but not a content-hash ETag. Sending both is normal and harmless. More in ETag explained.

6. Non-standard dates (measured)

We sent Last-Modified: 2026-09-22 10:00:00 (not IMF-fixdate). Chromium 153 still cached the page heuristically (no request on revisit, even after 8 s), but on revisits it never sent If-Modified-Since for it. Express does not validate a Last-Modified you set yourself: res.set('Last-Modified','2026-09-22') went out verbatim, and its freshness check parsed it leniently and returned 304 to a later If-Modified-Since. Lenient parsing is not something to rely on across clients and CDNs: always emit the GMT format.

7. Checklist

Frequently asked questions

Will a browser cache a page that has Last-Modified but no Cache-Control?

Yes. In our test Chromium 153 reused a page whose Last-Modified was 10 days old without contacting the server, both immediately and 8 seconds later. A page modified 1 minute earlier was reused only for a few seconds, then revalidated with If-Modified-Since and got a 304.

How long is heuristic freshness?

RFC 9111 suggests about 10% of the time since Last-Modified (from the RFC, not measured as an exact figure). Our results fit that: 10 days old stayed fresh past 8 seconds, 1 minute old (about 6 seconds) did not.

Does Express send Last-Modified?

Yes. Express 5.2.1 express.static sends Last-Modified from the file mtime, a weak ETag and Cache-Control: public, max-age=0, and answers If-Modified-Since with 304. Pass {lastModified: false} to drop the header.

Should I use Last-Modified or ETag?

Send both if your framework does. If both conditional headers arrive, If-None-Match wins (we saw Express return 200 when the ETag did not match even though the date did). ETag can catch changes within the same second; Last-Modified cannot.

Why did the page see 200 when the server sent 304?

The browser merges the 304 with its cached copy and hands the page a 200. Check DevTools Network or the server log to see the real status.

Written with AI assistance. Measured on 2026-10-02 with headless Chromium 153.0.8010.12 (Playwright) against a Node.js 24.21.0 http server on localhost:8983 that logged every request, and Express 5.2.1 (express.static) on localhost:8984 checked with curl 8.14.1. The 10% heuristic, the date format rules and the ETag precedence rule are quoted from RFC 9110/9111 and are labelled where not measured. PlainHeaders is our extension; Chromium, Node.js, Express and curl are free.