ETag and Conditional Requests for Tile Endpoints

On a versioned, immutable tile path an ETag does nothing: the client never revalidates, so the conditional request that would use it is never sent. ETags earn their place on exactly two resources in a tile deployment — the style document and any unversioned alias — and understanding why clarifies the whole caching scheme.

When to Use This

Any path whose content can change while its URL stays the same. In a well-arranged deployment that is a short list: style.json, an unversioned convenience alias like /tiles/latest/, and a dynamically generated tile endpoint where the tile genuinely changes.

Everywhere else, the versioned prefix means the URL changes when the content does, and revalidation is a round trip spent confirming something that cannot have changed.

Specification Detail

Header Sent by Purpose
ETag Server An opaque token identifying this exact body
Last-Modified Server A weaker, second-resolution alternative
If-None-Match Client “Send the body only if the ETag differs”
If-Modified-Since Client The Last-Modified equivalent
304 Not Modified Server Body unchanged; headers only

A strong ETag ("abc123") asserts byte equality. A weak one (W/"abc123") asserts semantic equivalence and is what most CDNs generate for compressed responses, since compression changes the bytes without changing the meaning.

What a conditional request saves, and what it still costsA client with a cached but stale style document sends If-None-Match, the origin compares the ETag and returns 304 with no body, and the client reuses its cached copy.ClientEdgeOriginGET /style.json, If-None-Match: "v43"TTL expired - revalidate304, no body304 - reuse the cached copyone round trip, ~200 bytes
A 304 saves the body, not the round trip. On a small document over a slow link, the round trip is most of the cost.

Production Configuration

The three path classes, each with the policy that fits it:

nginx
# 1. Versioned tiles: immutable, no revalidation, ETag pointless
location ~ ^/v[0-9a-f]+/.+\.(mvt|pmtiles)$ {
    add_header Cache-Control "public, max-age=31536000, immutable";
    etag off;                      # nothing will ever ask
    root /var/tiles;
}

# 2. The style document: short TTL, revalidation expected
location = /style.json {
    add_header Cache-Control "public, max-age=60, must-revalidate";
    etag on;
    root /var/tiles;
}

# 3. An unversioned alias: revalidate every time, serve 304 when unchanged
location ^~ /tiles/latest/ {
    add_header Cache-Control "public, max-age=0, must-revalidate";
    etag on;
    root /var/tiles;
}

And confirming the behaviour rather than assuming it:

bash
# Capture the ETag, then ask again with it
ETAG=$(curl -sI https://tiles.example.com/style.json | awk '/^etag:/ {print $2}' | tr -d '\r')
curl -sI -H "If-None-Match: $ETAG" https://tiles.example.com/style.json | head -1
# HTTP/2 304

# A versioned tile should not offer one at all
curl -sI https://tiles.example.com/v43/basemap/12/2048/1362.mvt | grep -ci etag
# 0
What immutable removes that an ETag cannotRequest counts on a page reload under four policies: no caching, max-age only, max-age with an ETag, and max-age with immutable.RELOAD COSTrequests on a reload, 40-tile viewportNo Cache-Control40 fullmax-age only40 fullmax-age + ETag40 x 304max-age + immutable0
A 304 is cheap but it is still a round trip. Forty of them on a cold mobile link is most of a second, and immutable removes all forty.
Which resources want an ETag and which want immutableFour path classes compared on whether they change under a stable URL, which caching directive fits, and whether an ETag does any work.POLICY BY PATHchanges in place?directiveETag useful?Versioned tilesNeverimmutableNoVersioned archiveNeverimmutableNostyle.jsonEvery deploymax-age=60YesUnversioned aliasEvery deploymust-revalidateYes
The two directives are mutually exclusive in practice. A path carrying both is a path where one of them is wrong.

Why Versioned Tiles Should Not Carry an ETag

It is harmless but misleading, and it invites a specific mistake. A team looking at a dashboard of 304 responses on tile paths concludes that revalidation is working well, when what it actually indicates is that immutable is missing and every reload is costing forty round trips.

There is also a subtler cost. Generating a strong ETag requires hashing the body, which for a tile server producing tiles on the fly is work done per request for a header nobody will use. Turning it off on immutable paths removes both the hashing and the confusion.

When ETags Actively Help

The style document. Fetched on every page load, changes on every deploy, and is small. A 304 costs a round trip and saves a few kilobytes — worthwhile, and more importantly it is what makes a sixty-second TTL cheap enough to keep.

An unversioned alias. Some consumers want a stable URL — a desktop GIS bookmark, an embed someone else maintains. Serving /tiles/latest/ with must-revalidate and an ETag gives them correctness at the cost of a round trip per request, which is the honest trade for a URL whose content changes.

A dynamic tile endpoint. Where tiles are generated from a database, an ETag derived from the underlying data’s last-modified timestamp lets a client skip the body when nothing changed. The saving is real when tiles are large and updates are infrequent.

Interaction Effects

With immutable. The two are mutually exclusive in practice. immutable tells the client never to revalidate; an ETag is only used during revalidation. A path with both is a path where one of them is wrong.

With compression. A CDN that compresses on the fly usually produces a weak ETag, because the compressed bytes differ from the origin’s. That is correct behaviour, and it means If-None-Match still works while byte-for-byte comparison does not.

With stale-while-revalidate. A better fit than a bare ETag for the style document: the client gets the stale copy immediately and the revalidation happens in the background, so the round trip is off the critical path entirely.

With versioned rotation. The reason ETags matter so little here is that versioning already solves the problem they address. A URL that changes with its content needs no mechanism for detecting that its content changed.

Performance Impact

Resource Policy Requests per reload Bytes
40 versioned tiles immutable 0 0
40 versioned tiles max-age + ETag 40 × 304 ~8 KB of headers
style.json max-age=60 + ETag 1 × 304 ~200 B
style.json no caching 1 full ~40 KB

The second row is the case worth avoiding, and it is the default a deployment falls into when immutable is omitted. The third and fourth show why the style document is where conditional requests genuinely pay.

Common Mistakes

Setting immutable and an ETag on the same path. One of them is doing nothing; usually it means immutable was added without removing the revalidation configuration.

Reading a high 304 rate on tiles as success. It means the immutable directive is missing.

Generating strong ETags on a dynamic tile server. Hashing every response body for a header that immutable clients ignore.

Relying on Last-Modified alone. Second resolution is too coarse for a resource that can change twice in a second, which a style document during a deploy can.

FAQ

Should tiles ever revalidate?

Only on an unversioned path. With a version prefix the URL is the version, so a tile at a given URL cannot change and revalidation has nothing to discover.

Is a weak ETag good enough?

For revalidation, yes — it is exactly what weak ETags are for. Strong ETags matter for range requests and byte-exact comparisons, neither of which applies to a style document.

What about stale-while-revalidate instead?

Usually better for the style document. Readers get an immediate response and the revalidation happens in the background, which removes the round trip from the critical path.

Do PMTiles range requests use ETags?

They can, and the important property is stability: an ETag that changes between range requests to the same object suggests the object was replaced mid-read, which is a 416 waiting to happen.