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-Modified | Revisit right away | Revisit after 8 s |
|---|---|---|
| 10 days old | served from cache, no request | served from cache, no request |
| 1 minute old | served from cache, no request | request with If-Modified-Since, got 304 |
| none (no validators at all) | request, full 200 | request, 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"
| Request | Status |
|---|---|
If-Modified-Since equal to Last-Modified | 304 |
If-Modified-Since later than Last-Modified | 304 |
If-Modified-Since earlier than Last-Modified | 200 |
If-Modified-Since: yesterday (not a date) | 200 |
Later If-Modified-Since + non-matching If-None-Match | 200 (ETag wins) |
After touch a.txt, old If-Modified-Since | 200, 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:
express.static(dir, {lastModified: false}): no Last-Modified header; a laterIf-Modified-Sincethen got 200. The ETag was still sent.express.static(dir, {etag: false}): Last-Modified only;If-Modified-Sincestill got 304.
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-Modified | ETag | |
|---|---|---|
| Request header | If-Modified-Since | If-None-Match |
| Resolution | 1 second | any change the server encodes |
| Heuristic freshness without Cache-Control | yes (measured above) | no (from the RFC, not measured) |
| Both sent | If-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
- Format:
Tue, 22 Sep 2026 10:00:00 GMT, never in the future. - Always pair it with explicit
Cache-Control, or browsers will pick a lifetime for you. - Answer
If-Modified-Sincewith 304 when unchanged (Express does this for you). - For per-user responses, also read private vs public.
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.