AIScan how-to cover: Let crawlers ask "changed?", with Cloudflare, NGINX and Apache chips and three steps: send an ETag, honour If-None-Match, test for a 304.
AIScan how-to cover: Let crawlers ask "changed?", with Cloudflare, NGINX and Apache chips and three steps: send an ETag, honour If-None-Match, test for a 304.
AI Readiness

Add ETag and Last-Modified so crawlers can send conditional requests

Send an ETag or Last-Modified header and answer repeat requests with a 304. Setup for nginx, Apache, Node, PHP, Next.js and Cloudflare, plus a two-request test.

27 Oct 2026 9 min read
#etag#last-modified#conditional-requests#http-caching#how-to

This guide covers D2 · Discoverability, D1 · Discoverability.

Table of contents

To make a page answer 304 Not Modified, send an ETag or a Last-Modified header on the first response, then answer a repeat request that carries If-None-Match or If-Modified-Since with status 304 and no body. Most web servers do this for static files on their own. Dynamic pages (WordPress, Node, PHP, a framework route) usually do not, and a CDN can quietly undo it. This guide shows the headers for six stacks and a two-request test that proves it, with results from tests we ran on 27 October 2026.

Written by M Asif Rahman. The adoption numbers live in our 300-host ETag study; this post is the how-to.

Quick summary

If you runDo thisTimeProof it worked
Static files on nginx or ApacheCheck the defaults are on (etag on;, FileETag MTime Size)5 mincurl -I shows ETag, a repeat request returns 304
A Node, PHP or Python routeHash the body, send ETag, compare If-None-Match, return 30420 minSame two-request test
Next.jsLeave generateEtags on (the default) for pages you want revalidated2 minETag on the HTML response
Behind CloudflareKeep origin ETags, decide on "Respect Strong ETags"10 min304 through the edge, not just at origin

The short version: send a validator that changes only when the content changes, answer the matching conditional request with an empty 304, and test through the same CDN your crawlers hit. Run npx aiscan-cli yoursite.com first to see how the files around this (sitemap, robots.txt) grade under check:D2 and check:D1. AIScan does not grade cache validators itself, so the two-request test below is manual.

Why a validator saves a crawler real work

A validator is a label the server attaches to one version of a response. RFC 9110 defines two: ETag (an opaque version string) and Last-Modified (a date). A client that kept the response sends the label back in If-None-Match or If-Modified-Since. If nothing changed, the server replies 304 with headers only.

Think of a courier collecting a parcel that is usually unchanged. Without a validator the courier carries the whole parcel back every visit. With one, the courier shows last week's receipt and the sender says "same parcel, keep what you have". Google's Search Central team describes the same flow in its December 2024 post on HTTP caching and recommends ETag over Last-Modified, with both set if you can.

Crawlers are not obliged to send conditional requests, and some never do. The cost of sending the headers is small and the benefit is that the ones that do can re-crawl cheaply. For measured adoption see the study linked above and the crawler traffic waste data.

What we tested

On 27 October 2026 we requested nine URLs on our four sites, then repeated each request with the validator it returned. Then we ran four small servers in a container and broke them on purpose.

URL (n = 9)ETagLast-ModifiedConditional result
xspeedcache.com /yesyes304 on both
xspeedcache.com /robots.txtyesyes304 on both
storeseo.com /noyes304 on If-Modified-Since
storeseo.com /robots.txtweak (W/"…")yes304 on both
bettershield.ai /yesno304 on If-None-Match
bettershield.ai /robots.txtyesno304 on If-None-Match
aiscan.site /nononothing to send back
aiscan.site /robots.txtnononothing to send back
aiscan.site, a blog postnononothing to send back

Six of nine URLs answered 304. The three that did not are all on this site, which sends Cache-Control: no-cache, must-revalidate, max-age=0 on its pages and no validator at all. That is the same finding as the study, and we have not fixed it yet. A page can tell crawlers to revalidate and still give them nothing to revalidate with.

Local server (27 Oct 2026)Validator sentConditional result
Node http, hand-writtenETag and Last-Modified304 on both; 200 again after we edited the file
Python http.serverLast-Modified only304 on If-Modified-Since
PHP built-in server, static filenoneno 304 possible
PHP script with a random ETagETag and Last-Modified200 every time

The last row is the mistake worth avoiding. A validator that changes on every request is worse than none: the header looks present, so a scan that only checks for its presence passes, and no crawler ever gets a 304.

Path 1: static files on nginx or Apache (check the defaults)

nginx generates ETag for static files by default. Per the nginx core module docs, etag is on unless you set it otherwise, and if_modified_since defaults to exact. Apache's FileETag directive defaults to MTime Size since 2.3.15. (Apache documents the older default as INode MTime Size, which gave different ETags on different servers behind a load balancer.)

  1. Request a static file and read the headers: curl -sI https://example.com/robots.txt.
  2. If ETag is missing on nginx, search your config for etag off; and remove it from the http, server or location block that serves the file. Reload nginx.
  3. If ETag is missing on Apache, search for FileETag None and replace it with FileETag MTime Size. Reload Apache.
  4. If you proxy to an application, the proxy only adds validators to files it serves itself. Your application has to send its own, which is Path 2.
  5. Run the two-request test below.

nginx and Apache may turn a strong ETag into a weak one when they compress a response on the fly, and the exact behaviour varies by version and module. A weak ETag still produces 304s for If-None-Match; it only matters for byte-range requests.

Path 2: dynamic pages (Node, PHP, Python, any framework)

The server has to do two things: compute a validator that depends only on content, and short-circuit when the request matches it. The tested Node version:

const crypto = require('crypto');
// body = the final HTML string or buffer you are about to send
const etag = '"' + crypto.createHash('sha256').update(body).digest('base64url').slice(0, 16) + '"';
res.setHeader('ETag', etag);
res.setHeader('Last-Modified', new Date(updatedAt).toUTCString());
res.setHeader('Cache-Control', 'no-cache');
const inm = req.headers['if-none-match'];
if (inm && inm.split(',').map(s => s.trim().replace(/^W\//, '')).includes(etag)) {
  res.statusCode = 304;
  return res.end();
}
res.end(body);

The PHP equivalent that passed our test:

$etag = '"' . substr(hash('sha256', $body), 0, 16) . '"';
header('ETag: ' . $etag);
header('Last-Modified: ' . gmdate('D, d M Y H:i:s', $mtime) . ' GMT');
header('Cache-Control: no-cache');
if (($_SERVER['HTTP_IF_NONE_MATCH'] ?? '') === $etag) { http_response_code(304); exit; }
echo $body;
  1. Build the final body first, with nothing time-dependent in it (no "generated at" stamp, no per-request nonce, no rotating ad ID).
  2. Hash the body and send it as a quoted ETag. Send Last-Modified from the real last-edited time of the content, never from "now".
  3. If the request has If-None-Match, compare against it and ignore If-Modified-Since. RFC 9110 gives If-None-Match priority.
  4. On a match, return 304 with the same validators and no body.
  5. Keep Cache-Control: no-cache if you want crawlers to ask every time. (no-cache means "revalidate before reuse", not "do not store".)

Hashing the body costs CPU on every request, so on a busy site store the hash with the page in your cache. For a Next.js app, generateEtags is on by default and you only touch it if you turned it off.

Path 3: a CDN in front (Cloudflare)

A CDN can make a good origin look broken. Cloudflare's ETag documentation says it supports strong and weak ETags set at your origin. With weak ETags you have to turn off features that rewrite HTML, such as Email Obfuscation and Automatic HTTPS Rewrites, or the edge can strip the header. The Cache Rules setting "Respect Strong ETags" controls whether Cloudflare keeps strong ETags; if it is off, Cloudflare converts them to weak ones. (Our crawl-control guide covers the dashboard side: Cloudflare AI Crawl Control setup.)

  1. Run the two-request test against the public hostname, not the origin IP.
  2. If the origin sends a strong ETag and the edge returns a weak one, check Caching > Cache Rules for a rule with Respect Strong ETags off.
  3. If the validator vanishes only on HTML, check the HTML-rewriting features named above.
  4. Repeat the test after any change to the rules.

Verify: the two-request test

Lead with the scan: npx aiscan-cli yoursite.com grades your sitemap and discovery files (check:D2, check:D1) so you know the files crawlers depend on are reachable. Then, because AIScan does not grade validators, check them by hand:

URL=https://example.com/
ET=$(curl -sI "$URL" | awk -F': ' 'tolower($1)=="etag"{print $2}' | tr -d '\r')
curl -s -o /dev/null -w '%{http_code}\n' -H "If-None-Match: $ET" "$URL"
ResultMeaningFirst fix
ET is emptyNo validator sentPath 1 or 2
304WorkingRepeat for Last-Modified with If-Modified-Since
200Validator sent but not honouredCheck the validator changes per request (Path 2 step 1) or a proxy dropped the header (Path 3)
Two runs give different ET valuesValidator is time-dependentRemove the timestamp or nonce from the body

Run it twice, a minute apart. The ET values must match when the content has not changed.

Who can skip this

A site that is mostly static and sits on nginx or Apache with defaults needs only the test. A site whose pages differ per visitor should only send validators on the public, anonymous version. And if your hosting layer already caches the page (see rate limiting without blocking crawlers for what to exempt), confirm the validators survive that layer before spending time on the origin.

Maintenance

Re-run the test after a theme or framework upgrade, a CDN rule change, or adding a plugin that injects a script with a timestamp. Any of these can silently change the body on every request. The robots.txt status and content type guide is a good companion check, since robots.txt is the first file a crawler asks for.

Next steps

Run npx aiscan-cli yoursite.com or paste your URL at aiscan.site to grade check:D1 and check:D2, run the two-request test on your homepage, sitemap and robots.txt, and read the other fixes in the guides and the discoverability checks.

Frequently asked questions

Do I need both ETag and Last-Modified?

No, one is enough for a 304, but set both if you can. Google's Search Central team recommends ETag over Last-Modified because it is less prone to errors, and Last-Modified still helps clients that only send If-Modified-Since.

My server sends an ETag but every conditional request returns 200. Why?

Usually the ETag changes on every request, because the body contains a timestamp or nonce, or a proxy rewrites it. Fetch the URL twice and compare the ETag values. If they differ, remove whatever is time-dependent from the body or hash only the stable part.

The ETag disappears when I put Cloudflare in front. What happened?

Check for HTML-rewriting features such as Email Obfuscation and Automatic HTTPS Rewrites, which Cloudflare's documentation says can strip weak ETags set at the origin. Also look for a Cache Rule with Respect Strong ETags turned off, which converts strong ETags to weak ones.

curl -I shows a Last-Modified date that is always the current time. Is that a problem?

Yes. A Last-Modified that equals the request time tells a crawler the page changed a moment ago, so it will never get a 304. Send the real last-edited time of the content instead.

Does a 304 response need a body?

No. A 304 carries headers only. Send the same ETag, Last-Modified and Cache-Control you would send on a 200, and leave the body empty.

What is the difference between a weak and a strong ETag?

A strong ETag changes whenever any byte of the response changes. A weak ETag, written W/"value", means the content is equivalent but not necessarily identical. Both give a 304 for If-None-Match; weak ones are not usable for byte-range requests.

Should I add validators to pages that differ per visitor?

Only on the public, anonymous version. A validator on a personalised page risks a 304 that makes one visitor keep another visitor's content, so personalised responses should skip validators or vary them.

Will ETag and Last-Modified make AI crawlers visit more often?

Not by themselves. They only make a repeat visit cheaper for crawlers that send conditional requests, and some do not. AIScan does not grade validators, so test them with the two-request curl check.

M Asif Rahman

About the author

Entrepreneur, curious about AI

I have been building companies for a long time, and lately most of my curiosity goes to AI. AIScan is a small personal project I built on a flight from Dubai to Narita. I use it to understand how ready the web is for AI agents, and I share what the scans teach me.

Related guides

Browse every guide by check and platform