Last-Modified Response Header Missing

Page updated but Google won't re-crawl. Check sitemap lastmod first, then fix the missing Last-Modified / ETag header so Googlebot can run conditional crawls.

You updated an article. Days pass. Google’s cached version still shows the old content. The dateModified in your JSON-LD is correct, the page itself renders the new content, but Google hasn’t re-crawled.

Fastest fix first: the strongest recrawl signal Google actually documents is an accurate <lastmod> in your XML sitemap, not the HTTP header. So step one is to confirm your sitemap reports the new modification date and that Search Console shows the sitemap as read. The Last-Modified HTTP header is the second lever: Googlebot sends an If-Modified-Since request on pages it has crawled before, and a correct Last-Modified lets your server answer 304 Not Modified, which saves crawl budget so Google can spend it on pages that did change. If your server never sends Last-Modified (or sends the same value for every page), conditional crawling can’t work and recrawl efficiency drops.

This header problem is most common on static hosts. Vercel and Netlify usually set Last-Modified and ETag correctly. Firebase Hosting, GitHub Pages, and some self-hosted nginx setups do not.

First, rule out the bigger cause: sitemap lastmod

Per Google Search Central, Google uses the sitemap <lastmod> value to prioritize recrawls only when it is consistently accurate. If you have lied about <lastmod> in the past (for example, stamping every page with the build time), Google lowers the trust score for your whole sitemap and starts ignoring the field. As of June 2026 this is still the primary freshness signal, and the <lastmod> is also NOT a ranking factor.

Check it before you touch HTTP headers:

# Confirm the page's lastmod in your sitemap reflects the real edit date
curl -s "https://yoursite.com/sitemap.xml" | grep -A2 "yoursite.com/article"

If <lastmod> is missing, stale, or identical across every URL, fix that first. In Google Search Console open Indexing -> Sitemaps, confirm the sitemap status is Success, then use URL Inspection -> Test Live URL -> Request Indexing on the specific page to force a one-off recrawl.

Only after the sitemap is honest does the Last-Modified header become the thing worth fixing.

Diagnosis: which bucket are you in?

Run one command and match the output:

curl -sI "https://yoursite.com/article" | grep -iE 'last-modified|etag|cache-control'
What you seeLikely causeJump to
No last-modified, no etagHost doesn’t emit either (Firebase / GitHub Pages / bare nginx)Cause 1
Origin has it, public URL doesn’tCDN strips the headerCause 2
Same last-modified on every pageValue is build time, not content timeCause 3
cache-control: no-store presentValidator is being discountedCause 4
etag present, no last-modifiedOnly half the validator setCause 5
Page is dynamic, header missingSSR/worker route doesn’t set itCause 6

Common causes

Ordered by hit rate, highest first.

1. Hosting platform doesn’t set Last-Modified

Firebase Hosting, GitHub Pages, and some custom nginx configs don’t automatically set Last-Modified for static files. As of June 2026 Firebase Hosting still ships a default Cache-Control: max-age=3600 and does not reliably serve a content-based Last-Modified or ETag-driven 304 from its CDN, so you have to set headers yourself.

How to spot it:

curl -sI "https://yoursite.com/article" | grep -i last-modified

Empty result = not set.

2. CDN strips the header

Origin returns Last-Modified. Cloudflare or another CDN strips it (or rewrites it to the cache-fill time) for caching reasons.

How to spot it: Two curl -I commands, one against your origin (bypassing the CDN) and one normal. If origin has Last-Modified but the public URL doesn’t, the CDN is the issue.

3. Last-Modified is always the same value (build time)

Some static-site generators set Last-Modified to the build time, so every page on the site shares one value. Google sees no per-page difference and treats it as noise rather than a signal, the same trust problem that hits a sitemap full of identical <lastmod> dates.

How to spot it: curl -I two different pages. If they share the same Last-Modified, this is the bug.

4. Cache-Control: no-store overrides

If you set Cache-Control: no-store, Last-Modified becomes meaningless because clients won’t cache anyway, and Googlebot can’t run a useful conditional request.

How to spot it:

curl -sI "https://yoursite.com/article" | grep -iE 'cache-control|last-modified'

If cache-control: no-store and last-modified are both present, that’s a conflict. For indexable HTML you usually want public, max-age=... or no-cache (revalidate), not no-store.

5. Using ETag instead, but missing Last-Modified

An ETag alone is valid and Googlebot can revalidate against it with If-None-Match. But Last-Modified is a human-readable date that doubles as a freshness hint, and pairing both is the safest setup. If you only emit ETag you are partially right but missing the more readable validator.

How to spot it: Headers have ETag but no Last-Modified. Add both.

6. Page is served by a worker / SSR function that doesn’t set headers

A Cloudflare Worker or Vercel/Next.js Edge function generating the page doesn’t add Last-Modified by default, and it won’t compute a 304 unless you write that logic.

How to spot it: Page is dynamically generated (check for a cf-ray header or a function server header) AND Last-Modified is missing.

Shortest path to fix

Step 1: Verify the missing header

curl -sI "https://yoursite.com/article" | head -n 12

Look for the Last-Modified line. Absent = need to add.

Step 2: Enable per-platform

Vercel — works by default for static files. For SSR / route handlers, set it on the response:

return new Response(html, {
  headers: { 'Last-Modified': new Date(article.modifiedAt).toUTCString() },
});

Netlify — works by default. Custom headers via a _headers file:

/article/*
  Last-Modified: Wed, 21 Oct 2025 07:28:00 GMT

Dynamic per-request values aren’t supported in _headers; use a Netlify Function or Edge Function when the value must vary per article.

Firebase Hosting — declare headers in firebase.json:

{
  "hosting": {
    "headers": [{
      "source": "**/*.html",
      "headers": [
        { "key": "Last-Modified", "value": "Wed, 21 Oct 2025 07:28:00 GMT" },
        { "key": "Cache-Control", "value": "public, max-age=600, must-revalidate" }
      ]
    }]
  }
}

Firebase static hosting won’t compute the date per file, so generate the firebase.json (or per-glob entries) from each article’s real modification time in your build script before firebase deploy. See the Firebase Hosting cache docs for the current header config syntax.

GitHub Pages — does not support custom response headers. If Last-Modified accuracy matters, move the site (or just the HTML routes) to a host that does, such as Cloudflare Pages, Netlify, or Firebase with a build-time header script.

Cloudflare Workers — set it on the Response manually:

return new Response(body, {
  headers: {
    'Last-Modified': lastModifiedDate.toUTCString(),
    'Cache-Control': 'public, max-age=3600',
  },
});

Step 3: Make the value reflect actual content change

Set it per article from the real edit time, never from build time for every page:

const lastModified = new Date(article.modifiedAt || article.publishedAt).toUTCString();

This is the single most important detail. A correct-but-identical timestamp everywhere is treated the same as no signal.

Step 4: Answer If-Modified-Since with a real 304

On dynamic routes, don’t just emit the header, honor the inbound conditional request so Googlebot gets a cheap 304:

const ims = request.headers.get('If-Modified-Since');
const lastModified = new Date(article.modifiedAt).toUTCString();
if (ims && new Date(ims) >= new Date(lastModified)) {
  return new Response(null, { status: 304, headers: { 'Last-Modified': lastModified } });
}
return new Response(html, { headers: { 'Last-Modified': lastModified } });

Static hosts do this for you; SSR/worker routes will not unless you add it.

Step 5: Don’t let the CDN strip it

Cloudflare -> Caching -> Configuration: confirm Last-Modified isn’t removed by a Transform Rule or “Remove response headers” rule. By default Cloudflare preserves origin Last-Modified and serves its own ETag-based revalidation, so the usual culprit is a custom rule, not the default.

Step 6: Verify origin and CDN match

# Origin (use the origin IP, an account-level workers.dev URL, or a skip-CDN host)
curl -sI "https://origin.yoursite.com/article" | grep -i last-modified

# Public CDN-served URL
curl -sI "https://yoursite.com/article" | grep -i last-modified

Both should show Last-Modified. If origin has it but the public URL doesn’t, the CDN is stripping it.

How to confirm it’s fixed

Reproduce the exact request Googlebot makes, a conditional GET, and confirm you get a 304:

# 1. Capture the current Last-Modified
LM=$(curl -sI "https://yoursite.com/article" | grep -i '^last-modified:' | sed 's/^[Ll]ast-[Mm]odified: //I' | tr -d '\r')
echo "Last-Modified: $LM"

# 2. Re-request with If-Modified-Since; expect "HTTP/2 304"
curl -sI -H "If-Modified-Since: $LM" "https://yoursite.com/article" | head -n 1

A 304 Not Modified means conditional crawling now works. A 200 means your server (or CDN) is ignoring the validator and Googlebot will keep paying full crawl cost. After an edit, the new Last-Modified should be strictly later than the old one and the same request should then return 200 until the next conditional check.

Then close the loop in Search Console: URL Inspection -> Test Live URL to confirm Google fetches the new content, and watch the Page indexing report over the next few days for the “Last crawl” date to advance.

Prevention

  • Keep your sitemap <lastmod> honest and per-page accurate. That is the primary recrawl signal; the HTTP header is the efficiency layer underneath it.
  • Right after every deploy, curl -I three representative pages and confirm Last-Modified is present and per-page distinct.
  • Derive Last-Modified from each article’s real modifiedAt, never from build time.
  • Don’t strip the header in CDN config, and don’t pin indexable HTML to Cache-Control: no-store.
  • Pair Last-Modified with ETag so revalidation works via either If-Modified-Since or If-None-Match.
  • For SSR / worker routes, implement the conditional-GET branch so Googlebot actually receives 304s.

FAQ

Will adding Last-Modified make Google re-crawl immediately? No. It mainly makes future crawls cheaper (via 304) and gives Google a freshness validator. The faster trigger is an accurate sitemap <lastmod> plus a manual Request Indexing in Search Console. Header-only changes can take days to show up.

Is Last-Modified a ranking factor? No. Neither the header nor the sitemap <lastmod> is used for ranking. They influence crawl scheduling and efficiency, not position. Fresh content can rank better, but the date metadata itself is not the lever.

ETag or Last-Modified, which should I use? Use both if you can. ETag is a content fingerprint (validated with If-None-Match) and is more precise; Last-Modified is a date (validated with If-Modified-Since) and is human-readable. Most hosts that set one set the other.

My header shows the same date on every page. Is that a problem? Yes. A site-wide identical Last-Modified (usually the build time) tells Google nothing about which page changed, so it’s treated like no signal. Generate the value from each article’s edit time.

Firebase Hosting won’t add Last-Modified. What now? Firebase doesn’t compute a per-file Last-Modified. As of June 2026 the supported path is to write the headers (per glob or per page) into firebase.json from a build script that reads each article’s real modification time, then firebase deploy. There is no runtime conditional-GET on static Firebase Hosting, so the value must be set at deploy time.

Why does my SSR route return 200 to If-Modified-Since? Because SSR/edge functions don’t honor conditional requests automatically. You have to read the inbound If-Modified-Since header, compare it to the article’s modifiedAt, and return 304 yourself (see Step 4).

Tags: #SEO #Troubleshooting