HTTP Caching for Spatial Data: ETags, Max-Age and Invalidation

Problem statement

Caching is the cheapest performance work available on any HTTP service, and spatial services benefit more than most: tiles are immutable for their content, feature responses repeat, and the payloads are large.

The measured difference on one real endpoint:

                          throughput      p50 latency    bytes on the wire
generate and send             100 rps         94.6 ms          340,521
answer 304 Not Modified     1,191 rps          6.2 ms                0

Twelve times the throughput and a fifteenth of the latency, for an ETag header and a string comparison.

The reason it is so often missing is that caching looks like an infrastructure concern. It is not: Cache-Control, ETag and Vary are three response headers, and getting them right is a design decision about how stale each resource may be.

Quick answer

Three headers, chosen per resource:

from fastapi import Request, Response
import hashlib


def cached_response(body: bytes, request: Request, media_type: str,
                    max_age: int = 300, immutable: bool = False):
    etag = '"' + hashlib.sha256(body).hexdigest()[:16] + '"'
    if request.headers.get("if-none-match") == etag:
        return Response(status_code=304, headers={"ETag": etag})

    cache_control = (f"public, max-age={max_age}, immutable" if immutable
                     else f"public, max-age={max_age}")
    return Response(body, media_type=media_type,
                    headers={"ETag": etag, "Cache-Control": cache_control,
                             "Vary": "Accept, Accept-Encoding"})
tiles at a versioned URL   public, max-age=31536000, immutable
tiles at a live URL        public, max-age=60
feature queries            public, max-age=300  + ETag
a downloadable file        public, max-age=86400 + ETag
anything user-specific     private, no-store
Bar chart of throughput for generating, revalidating and caching a response.
No query tuning approaches the speed of not running a query.

Step-by-step solution

1. Decide how stale each resource may be

This is the only genuinely hard part, and it is a product question rather than a technical one. "How often does the data change?" is the wrong question; "how out of date may a response be before somebody is misled?" is the right one.

  • Immutable โ€” a tile at a versioned URL, a file with a content hash in its name. Never revalidate.
  • Minutes โ€” feature queries over slowly changing data.
  • Seconds โ€” live positions, editing sessions.
  • Never cache โ€” anything user-specific or authenticated, unless marked private.

2. Use ETag for revalidation

An ETag is an opaque identifier for the response body. The client sends it back as If-None-Match, and the server answers 304 Not Modified with no body if it still matches.

The saving is the body plus the transfer, and โ€” if the response is precomputed โ€” the generation too. Measured, that was 340 kB and 88 ms per request.

Compute it from the body when the body is cheap to produce, or from a data version when it is not:

etag = f'"{layer_version}-{query_hash}"'

3. Use Cache-Control for the freshness window

max-age says how long a response may be reused without asking. During that window the client does not contact the server at all โ€” which is strictly better than a 304, because there is no round trip.

Cache-Control: public, max-age=300

public allows shared caches (a CDN, a proxy) to store it; private restricts it to the browser. Use private for anything that depends on who is asking.

4. Version URLs so tiles can be immutable

immutable tells the client never to revalidate, even on a reload. It is only safe when the URL's content genuinely cannot change โ€” which you achieve by putting a version in the path:

/tiles/v7/{z}/{x}/{y}.mvt      public, max-age=31536000, immutable

A data update becomes /tiles/v8/โ€ฆ, and nothing has to be purged: the old URLs simply stop being requested. This is the cleanest invalidation strategy available, and it is why it is worth designing the URL for it from the start.

5. Set Vary correctly, or caches will serve the wrong thing

A shared cache keys on the URL. If the response depends on a request header โ€” the format negotiated through Accept, the compression through Accept-Encoding โ€” the cache must be told, or it will serve a GeoJSON response to a client that asked for Parquet.

Vary: Accept, Accept-Encoding

Keep the list short. Every header in Vary multiplies the number of cache entries; Vary: * disables caching entirely.

6. Compress, and let the cache know

Gzip is not caching, and it compounds with it. Measured on a real feature response: 857,061 bytes became 299,123 on the wire, a factor of 2.87.

Include Accept-Encoding in Vary so a cache does not serve a compressed body to a client that cannot decompress it.

Grid of four resource types with the cache policy each needs.
The headers are where the decision lives, next to the endpoint that knows the data.

Code examples

Example 1 โ€” a caching policy per resource type

from dataclasses import dataclass
from fastapi import Request, Response
import hashlib


@dataclass
class CachePolicy:
    max_age: int
    public: bool = True
    immutable: bool = False
    etag: bool = True
    vary: tuple = ("Accept", "Accept-Encoding")

    def headers(self, etag_value=None):
        directives = ["public" if self.public else "private",
                      f"max-age={self.max_age}"]
        if self.immutable:
            directives.append("immutable")
        headers = {"Cache-Control": ", ".join(directives)}
        if self.vary:
            headers["Vary"] = ", ".join(self.vary)
        if etag_value:
            headers["ETag"] = etag_value
        return headers


POLICIES = {
    "tile_versioned": CachePolicy(max_age=31_536_000, immutable=True, etag=False),
    "tile_live":      CachePolicy(max_age=60),
    "features":       CachePolicy(max_age=300),
    "file_download":  CachePolicy(max_age=86_400),
    "user_specific":  CachePolicy(max_age=0, public=False, etag=False),
}


def respond(body: bytes, request: Request, media_type: str, policy_name: str):
    policy = POLICIES[policy_name]
    etag = ('"' + hashlib.sha256(body).hexdigest()[:16] + '"') if policy.etag else None

    if etag and request.headers.get("if-none-match") == etag:
        return Response(status_code=304, headers=policy.headers(etag))

    return Response(body, media_type=media_type, headers=policy.headers(etag))

Example 2 โ€” an ETag from a data version rather than the body

import hashlib
import json


def query_etag(layer_version: str, params: dict) -> str:
    """Cheap: no need to generate the body to know whether it changed."""
    key = json.dumps({"v": layer_version, **params}, sort_keys=True)
    return '"' + hashlib.sha256(key.encode()).hexdigest()[:16] + '"'


@app.get("/features")
def features(request: Request, bbox: str | None = None, limit: int = 50):
    etag = query_etag(LAYER_VERSION, {"bbox": bbox, "limit": limit})
    if request.headers.get("if-none-match") == etag:
        return Response(status_code=304, headers={"ETag": etag})

    body = build_features(bbox, limit)          # only now do the work
    return Response(body, media_type="application/geo+json",
                    headers={"ETag": etag, "Cache-Control": "public, max-age=300"})

This is the version worth having on an expensive endpoint: the 304 path never touches the data at all. It requires a LAYER_VERSION that changes whenever the data does โ€” a timestamp, a load id, a content hash computed at ingest.

Example 3 โ€” measuring what caching buys on your own service

import asyncio
import statistics
import time
import httpx


async def measure(url, n=200, concurrency=10, headers=None):
    limits = httpx.Limits(max_connections=concurrency)
    async with httpx.AsyncClient(limits=limits, timeout=60) as client:
        await client.get(url, headers=headers or {})          # warm
        latencies = []
        semaphore = asyncio.Semaphore(concurrency)

        async def one():
            async with semaphore:
                started = time.perf_counter()
                response = await client.get(url, headers=headers or {})
                latencies.append(time.perf_counter() - started)
                return response

        started = time.perf_counter()
        responses = await asyncio.gather(*[one() for _ in range(n)])
        wall = time.perf_counter() - started

    latencies.sort()
    return {"status": responses[0].status_code,
            "rps": round(n / wall, 1),
            "p50_ms": round(statistics.median(latencies) * 1000, 1),
            "bytes": int(responses[0].headers.get("content-length", 0))}


async def compare(url):
    async with httpx.AsyncClient() as client:
        etag = (await client.get(url)).headers.get("etag")

    cold = await measure(url)
    warm = await measure(url, headers={"If-None-Match": etag})
    print(f"200 path  {cold['rps']:7.1f} rps  p50 {cold['p50_ms']:6.1f} ms  "
          f"{cold['bytes']:,} bytes")
    print(f"304 path  {warm['rps']:7.1f} rps  p50 {warm['p50_ms']:6.1f} ms  "
          f"{warm['bytes']:,} bytes")
200 path    100.2 rps  p50   94.6 ms  340,521 bytes
304 path  1,191.0 rps  p50    6.2 ms  0 bytes

Explanation

Why max-age beats ETag when both apply

An ETag saves the body; max-age saves the request. Within the freshness window the client does not contact the server at all, so the saving includes the round trip โ€” which on a mobile network is frequently the dominant cost.

Use both: max-age for the window in which staleness is acceptable, and an ETag so that revalidation after the window is cheap. The measured 304 path at 1,191 requests per second is the revalidation case; the cached case costs the server nothing whatsoever.

Why immutable content is the strongest form

immutable removes revalidation even on a reload, which is otherwise the one action that bypasses max-age. It is only correct when the URL's content cannot change โ€” and versioning the URL is how you make that true.

The consequence for tiles is significant: with a versioned prefix, a CDN never asks the origin about a tile again, and a data update is a deployment of a new prefix rather than a purge of millions of objects.

Why Vary is the header that causes silent bugs

A cache stores a response against a URL. If two clients ask for the same URL and receive different bodies โ€” one GeoJSON, one Parquet, because they sent different Accept headers โ€” the cache will serve whichever it stored to both.

Vary: Accept tells it to key on that header too. Forgetting it produces a bug that only appears through a cache, only for some clients, and is close to impossible to reproduce locally.

Why caching is a design decision rather than a deployment one

The freshness window encodes a product decision: how out of date a response may be. Nobody but the people who understand the data can answer that, and it is different per resource โ€” tiles for a boundary layer can be immutable for a year, positions from a live feed for five seconds.

Putting a CDN in front of a service that sends no cache headers achieves very little, because the CDN's defaults are conservative. The headers are where the decision lives, and they belong next to the endpoint that knows what the data is.

Two panels showing a cache serving the wrong format without Vary, and correctly with it.
Add the header at the same moment you make the response depend on a request header.

Edge cases or notes

  • no-store means never write it down; no-cache means revalidate before use. They are not synonyms.
  • private for anything user-specific, or a shared cache may serve one user's data to another.
  • A weak ETag (W/"โ€ฆ") allows semantically equivalent bodies to match; strong ETags require byte equality.
  • Vary: * disables caching. Keep the list short and specific.
  • A reload bypasses max-age unless the response is immutable.
  • Compress and cache โ€” they compound; measured, gzip alone was 2.87ร—.
  • 304 responses must repeat the ETag and any headers that would have varied.
  • Version the URL rather than purging a CDN. Purging millions of tiles is slow and often incomplete.

FAQ

How much does HTTP caching actually save?

Measured on one endpoint: 1,191 requests per second and 6.2 ms for a 304 response, against 100 requests per second and 94.6 ms for generating and sending the body.

What is the difference between ETag and max-age?

max-age says how long a response may be reused without asking, so it saves the whole round trip. ETag makes the ask cheap when it happens, by allowing a 304 with no body.

When can I use immutable?

When the URL's content cannot change โ€” which you guarantee by putting a version in the path. Then a data update is a new prefix and nothing needs purging.

Why do I need Vary?

Because caches key on the URL. If the response depends on Accept or Accept-Encoding, a cache without Vary will serve the wrong format or a compressed body to a client that cannot read it.

What cache lifetime should feature queries use?

However stale a response may be before somebody is misled โ€” usually minutes. It is a product decision, not a technical one, and it differs per resource.

Should I still compress if I cache?

Yes. They compound: caching removes requests, compression shrinks the ones that remain. Measured, gzip took a response from 857 kB to 299 kB.