ETag Header: Definition and Use in Screenshots

An ETag header is a version tag a server attaches to a response. Learn how If-None-Match and 304 work, and how to re-capture a page only when it changes.

By Simon Chiu · Updated

An ETag (entity tag) is an HTTP response header that carries an opaque version identifier for a resource, so a client can later ask "has this changed?" with If-None-Match and get a short 304 Not Modified answer when it has not.

For screenshot work, ETags answer one useful question cheaply: do I need to capture this page again? This entry is part of the screenshot and rendering glossary.

A plain explanation

When a server sends a page, it can label the response with a tag, for example ETag: "news-v1". The value means nothing to the client. The client keeps it with its copy. Next time, it sends the tag back in If-None-Match. If the server's current tag is the same, it answers 304 Not Modified with no body, and the client reuses its copy. If the tag differs, the server sends the new page with the new tag.

first request                       later request
client ──GET /news──────────▶       client ──GET /news, If-None-Match: "news-v1"──▶
       ◀─200, ETag: "news-v1"─             ◀─304 Not Modified (no body)──────────── unchanged
         + full page                       ◀─200, ETag: "news-v2" + full page─────── changed

Last-Modified and If-Modified-Since do the same job with a date instead of a tag. Some servers send only that pair. On 2026-10-15, https://example.com answered with Last-Modified and no ETag, so a change check against it has to use the date.

How it works technically

  • Strong and weak tags. "abc" is strong: byte-for-byte identical. W/"abc" is weak: equivalent content. If-None-Match uses weak comparison, so both work.
  • The server chooses how to compute it. Common choices are a hash of the body, a database row version or file size plus modification time. A tag computed from a body that includes a timestamp or a CSRF token changes on every request and is useless for change detection.
  • One tag per response. The HTML document has its own ETag. Each image, stylesheet, font and API call has a separate one.
  • Cache-Control decides when to ask. no-cache means "revalidate every time", which is when the ETag is sent. A fresh max-age skips the question entirely. See Cache-Control headers.

What a headless browser does with ETags

We ran a Playwright test against a local page that sends ETag: "news-v2" and Cache-Control: no-cache, with two visits in each of two fresh browser contexts:

Visit If-None-Match sent Server answered Playwright reported
Context A, visit 1 none 200 200
Context A, visit 2 "news-v2" 304 200
Context B, visit 1 none 200 200
Context B, visit 2 "news-v2" 304 200

Two facts for capture code follow. A fresh context has an empty cache, so the first capture never revalidates. And when the server returns 304, Chromium serves its cached copy and reports 200 to your script, so response.status() cannot tell you whether the page changed. Compare tags yourself.

import { chromium } from 'playwright';

// Show what the browser sends and gets back when it already holds a cached copy.
const url = process.argv[2] ?? 'http://localhost:4630/news';
const browser = await chromium.launch();
try {
  for (const label of ['context A', 'context B']) {
    const context = await browser.newContext(); // each context starts with an empty cache
    const page = await context.newPage();
    for (const visit of [1, 2]) {
      const res = await page.goto(url);
      const sent = await res.request().allHeaders();
      console.log(label, `visit ${visit}:`, 'If-None-Match =', sent['if-none-match'] ?? '(none)',
        '| status', res.status(), '| ETag', res.headers().etag);
    }
    await context.close();
  }
} finally {
  await browser.close();
}

Re-capture only when the page changes

Puppeteer. A HEAD request with If-None-Match costs one round trip and no browser. Launch Chrome only when the answer is not 304.

import { readFile, writeFile } from 'node:fs/promises';
import puppeteer from 'puppeteer';

// Re-capture a page only when its ETag changes. State lives in a small JSON file.
const url = process.argv[2] ?? 'http://localhost:4630/news';
const stateFile = 'out/etag-state.json';
const state = JSON.parse(await readFile(stateFile, 'utf8').catch(() => '{}'));

const head = await fetch(url, {
  method: 'HEAD',
  headers: state[url] ? { 'If-None-Match': state[url] } : {},
});
if (head.status === 304) {
  console.log('304 Not Modified: keep the existing screenshot');
  process.exit(0);
}
if (!head.ok) throw new Error(`HEAD ${url} returned ${head.status}`);

const browser = await puppeteer.launch();
try {
  const page = await browser.newPage();
  const res = await page.goto(url, { waitUntil: 'load' });
  await page.screenshot({ path: 'out/news.png' });
  // Store the ETag of the document we actually captured, not the HEAD response.
  state[url] = res.headers().etag ?? null;
  await writeFile(stateFile, JSON.stringify(state, null, 2));
  console.log(`captured, ETag ${state[url]}`);
} finally {
  await browser.close();
}

Three runs, with a content change before the third, printed captured, ETag "news-v1", then 304 Not Modified: keep the existing screenshot, then captured, ETag "news-v2".

RenderScreenshot. The API caches renders by a cache key made from your rendering options, not from the target's ETag. Keep the same HEAD check in your code, and when the tag changes, ask for a fresh render with cache.refresh so the new image replaces the old one. See caching.

import { Client, TakeOptions } from 'renderscreenshot';

const client = new Client(process.env.RS_API_KEY);

// Call this only after your If-None-Match check returned 200, not 304.
const image = await client.take(
  TakeOptions.url('https://example.com/news').preset('og_card').cacheRefresh()
);

To remove older renders of that page from the CDN as well, purge by URL pattern with client.cache.purgeUrl('https://example.com/news*'). The cache invalidation guide covers purge-on-deploy patterns.

Tested on 2026-10-15 with Node 22.18, Puppeteer 24.43.1 (headless shell, Chrome 148) and Playwright 1.64.1 (Chromium 156) against a local server that sends ETags; the server log confirmed the two 304 answers. The RenderScreenshot snippet follows the SDK docs and was not run for this page.

Common mistakes

  • Trusting the document ETag for visual changes. A new hero image or a CSS deploy changes the screenshot without touching the HTML tag.
  • Tags that change every request. If the tag is a hash of a body with a nonce or timestamp, every check says "changed". Test with two HEAD requests in a row.
  • Reading response.status() in the browser. A 304 from the server becomes 200 in Puppeteer and Playwright. Use a plain HTTP client for the check.
  • Assuming every server sends one. Fall back to Last-Modified, or to a hash of the HTML you fetch.

For scheduled re-captures, combine this check with the cache invalidation guide. To try it, get a free API key.

Frequently asked questions

What is the difference between a strong and a weak ETag?
A strong ETag such as "v2" promises the bytes are identical. A weak ETag, written W/"v2", only promises the content means the same thing, so a server can keep it when it changes whitespace or compression. Both work for If-None-Match.
Does a page's ETag tell me its screenshot changed?
It tells you the HTML document changed or did not. A screenshot can still change with the same ETag, because images, CSS, fonts and data from APIs are separate responses with their own ETags.
Do screenshot APIs use the target page's ETag?
RenderScreenshot's cache key is built from your rendering options, not from the target's headers. If you want a new capture when the page changes, check the ETag yourself and send cache.refresh.

Related in this topic

Try RenderScreenshot free

150 free credits every month, no credit card required.