Skip to content

Cache-Control vs. ETag: What Each One Actually Controls

Open a browser’s network tab and you’ll see Cache-Control and ETag sitting on the response headers of nearly every image, stylesheet, and script. Most developers recognize both names, but far fewer could explain how they actually divide the work of caching between them. This post walks through the basics of HTTP caching, then looks at how this project’s own OGP image generator uses one of these headers and deliberately skips the other.

The Problem These Headers Solve

Note: HTTP caching lets a browser (or a CDN in between) hold on to a response it already fetched — an image, a stylesheet, a script — and reuse it on the next request for the same URL instead of asking the server again.

Without any instructions from the server, a browser has to guess whether a cached response is still safe to reuse, and that guess tends to be inconsistent across browsers and situations. Cache-Control and ETag exist to remove the guesswork by having the server state its intent explicitly. But they answer two different questions: Cache-Control answers “how long can you skip asking me entirely?” and ETag answers “once that period is over, how can you cheaply check whether anything actually changed?”

What Cache-Control Controls

Cache-Control sets an expiration window. This project’s blog theme includes ogp-generator.php, which renders an OGP preview image with PHP’s GD library for any post that has no featured image set. When it serves the generated PNG, it attaches:

header('Content-Type: image/png');
header('Content-Length: ' . filesize($file));
header('Cache-Control: public, max-age=2592000, immutable');

Each directive does something distinct:

  • public — this response can be cached not just by the requesting browser but by any shared cache along the way, such as a CDN. (A response with per-user content, like a personal dashboard, would use private instead.)
  • max-age=2592000 — the number of seconds the cache is considered fresh. 2,592,000 seconds is exactly 30 days; for that entire window, a browser revisiting the same URL never contacts the server at all.
  • immutable — a declaration that the content will not change for the duration of max-age. With this set, the browser skips even the lightweight revalidation check described below on a page reload — it just uses the cached copy, no questions asked.

Pairing a 30-day window with immutable only makes sense if “this exact URL will never point to different content” genuinely holds. How that guarantee gets built is the interesting part.

What ETag Controls — Checking In After Expiration

Once max-age expires, a browser doesn’t simply throw the cached copy away — it asks the server whether the old copy is still good. ETag (short for Entity Tag) is what makes that check cheap.

Note: an ETag is a short identifier — usually a hash — generated from a response’s actual content. If even a single byte of the content changes, the ETag changes with it, making it effectively a fingerprint of the content.

The revalidation flow looks like this:

  1. The server’s first response includes something like ETag: "abc123".
  2. The browser remembers that value, and once the cached copy has expired, it sends a follow-up request carrying If-None-Match: "abc123".
  3. The server recomputes the content’s current ETag. If it hasn’t changed, it replies with 304 Not Modified — a lightweight response with no body.
  4. The browser sees the 304 and keeps using its existing cached copy.

The key detail is that a 304 Not Modified response never re-transmits the actual image or file — the server still has to do the work of recomputing whether the content changed, but the network transfer itself is skipped. Where max-age is a blunt instrument (“skip checking entirely for this long”), ETag is a finer one (“even after the window closes, skip re-downloading if nothing actually changed”).

Why ogp-generator.php Doesn’t Use ETag

Given the above, it might seem like ogp-generator.php is missing something by not implementing ETag. Looking at the actual code shows the opposite: the design makes revalidation unnecessary in the first place.

$modkey  = get_post_modified_time('YmdHis', true, $post); // GMT-based
$cache_file = $cache_dir . "/post-{$post_id}-{$lang}-{$modkey}.png";

The generated filename bakes in the post ID, the language, and the post’s last-modified timestamp. When a post is edited and post_modified changes, the resulting PNG’s filename — and therefore its URL — changes along with it. This is a technique commonly called cache busting: instead of asking “has this URL’s content changed?”, the system makes sure a given URL’s content can never change in the first place, because any real change produces a different URL.

Under that design, there’s nothing for ETag to check — a given URL is guaranteed, by its own structure, to point at content that was frozen the moment it was generated. That guarantee is exactly what makes it safe to attach immutable. If the generator instead reused the same filename after a post’s title changed, immutable would be lying to the browser, and editors would see stale OGP images persist for up to 30 days after every edit.

Both approaches solve the same underlying problem — keeping a cache from serving stale content — but from opposite directions. ETag accepts that the URL stays fixed and pays a small revalidation cost on every expiration cycle to confirm freshness. Baking a version into the URL itself avoids that round trip entirely, at the cost of only working cleanly when there’s a reliable signal (here, post_modified) to version against. Which approach fits depends on how often, and at what granularity, the underlying content actually changes.

Takeaway

Cache-Control and ETag both exist to control caching, but they control different parts of it. Cache-Control‘s max-age controls how long a browser can skip contacting the server entirely. ETag controls how cheaply the server and browser can confirm, once that window closes, whether the content has genuinely changed. Baking a content version into the URL itself — as ogp-generator.php does with a post’s modification timestamp — sidesteps the need for ETag altogether, making a long max-age with immutable safe rather than reckless. Whichever strategy is used, the caching logic is only as trustworthy as the assumption it’s built on about when the underlying content actually changes.