How to Set Cache Headers on a Spatial API and Tile Service
Problem statement
The service is correct and slow, and the profiler shows the time going into work that has already been done: the same tile encoded again, the same bounding-box query executed again, the same 340 kB body serialised and sent again.
Measured on a real FastAPI endpoint, the difference between doing that work and not:
throughput p50 latency bytes sent
generate and send 100 rps 94.6 ms 340,521
answer 304 1,191 rps 6.2 ms 0
Three response headers separate those two rows. This guide is how to add them to a spatial service without getting the details wrong โ because a mis-set Vary produces a bug that only appears through a CDN, and an over-long max-age produces stale data nobody can purge.
Quick answer
A dependency that handles ETag matching and the header set:
import hashlib
from dataclasses import dataclass
from fastapi import FastAPI, Request, Response
app = FastAPI()
@dataclass(frozen=True)
class Policy:
max_age: int
public: bool = True
immutable: bool = False
vary: tuple = ("Accept", "Accept-Encoding")
def cached(body: bytes, request: Request, media_type: str, policy: Policy,
etag_source: bytes | str | None = None):
seed = etag_source if etag_source is not None else body
if isinstance(seed, str):
seed = seed.encode()
etag = '"' + hashlib.sha256(seed).hexdigest()[:16] + '"'
directives = ["public" if policy.public else "private", f"max-age={policy.max_age}"]
if policy.immutable:
directives.append("immutable")
headers = {"ETag": etag, "Cache-Control": ", ".join(directives)}
if policy.vary:
headers["Vary"] = ", ".join(policy.vary)
if request.headers.get("if-none-match") == etag:
return Response(status_code=304, headers=headers)
return Response(body, media_type=media_type, headers=headers)
TILE_VERSIONED = Policy(max_age=31_536_000, immutable=True, vary=())
FEATURES = Policy(max_age=300)
DOWNLOAD = Policy(max_age=86_400)
PRIVATE = Policy(max_age=0, public=False, vary=())
Step-by-step solution
1. Give each endpoint an explicit policy
Not a default, and not a middleware that applies one rule everywhere. A tile at a versioned URL and a user-specific query need opposite treatment, and the difference is a product decision:
| Endpoint | Policy | Why |
|---|---|---|
/tiles/v7/{z}/{x}/{y} |
max-age=31536000, immutable |
the URL cannot change content |
/tiles/live/{z}/{x}/{y} |
max-age=60 |
may be a minute stale |
/features?bbox=โฆ |
max-age=300 + ETag |
slowly changing |
/download/x.parquet |
max-age=86400 + ETag |
a published artefact |
/my/features |
private, no-store |
depends on who is asking |
2. Compute the ETag cheaply
Hashing the body is correct and requires producing the body. On an expensive endpoint, derive the ETag from a data version and the query instead:
def query_etag(layer_version: str, **params) -> str:
import json
key = json.dumps({"v": layer_version, **params}, sort_keys=True)
return '"' + hashlib.sha256(key.encode()).hexdigest()[:16] + '"'
Then the 304 path never touches the data. That is the version that turns 94.6 ms into 6.2 ms rather than merely saving the transfer.
3. Get Vary right
A shared cache stores one response per URL. If the response depends on a request header, say so:
headers["Vary"] = "Accept, Accept-Encoding"
Accept because the endpoint may return GeoJSON or Parquet; Accept-Encoding because the body may be gzipped. Omit either and a CDN will eventually serve a Parquet body to a client that asked for GeoJSON, for one client and not another, in a way that does not reproduce locally.
Keep the list minimal โ every varying header multiplies the cache entries.
4. Version tile URLs so immutable is honest
DATA_VERSION = os.environ.get("TILE_VERSION", "v1")
@app.get("/tiles/{version}/{z}/{x}/{y}.mvt")
def tile(version: str, z: int, x: int, y: int, request: Request):
body = build_tile(z, x, y)
policy = TILE_VERSIONED if version != "live" else Policy(max_age=60)
return cached(body, request, "application/vnd.mapbox-vector-tile", policy)
immutable stops browsers revalidating even on a reload, which is otherwise the one action that bypasses max-age. It is only true because the version in the path changes when the data does.
5. Handle conditional requests correctly
Two rules that are easy to get wrong:
- A 304 must carry the same
ETag,Cache-ControlandVaryit would have sent with a 200. - A 304 must have no body, and
Content-Lengthmust not claim one.
The helper above does both by constructing the headers before deciding which status to return.
6. Compress, and let caches know
from fastapi.middleware.gzip import GZipMiddleware
app.add_middleware(GZipMiddleware, minimum_size=1000)
Measured on a real feature response, gzip took 857,061 bytes to 299,123 on the wire โ a factor of 2.87. With Accept-Encoding in Vary, a cache stores the compressed and uncompressed variants separately and serves each client the right one.
Code examples
Example 1 โ the policies applied across a service
import hashlib
import os
from fastapi import FastAPI, Query, Request, Response
from fastapi.middleware.gzip import GZipMiddleware
import geopandas as gpd
app = FastAPI()
app.add_middleware(GZipMiddleware, minimum_size=1000)
LAYER = gpd.read_file("provinces.gpkg")
LAYER_VERSION = os.environ.get("LAYER_VERSION", "2026-09-05")
TILE_VERSIONED = Policy(max_age=31_536_000, immutable=True, vary=())
FEATURES = Policy(max_age=300)
@app.get("/features")
def features(request: Request, bbox: str | None = None,
limit: int = Query(50, ge=1, le=200)):
etag_seed = f"{LAYER_VERSION}|{bbox}|{limit}"
etag = '"' + hashlib.sha256(etag_seed.encode()).hexdigest()[:16] + '"'
if request.headers.get("if-none-match") == etag:
return Response(status_code=304, headers={
"ETag": etag, "Cache-Control": "public, max-age=300",
"Vary": "Accept, Accept-Encoding"})
subset = LAYER
if bbox:
x0, y0, x1, y1 = (float(v) for v in bbox.split(","))
subset = subset.cx[x0:x1, y0:y1]
body = subset.iloc[:limit].to_json().encode()
return cached(body, request, "application/geo+json", FEATURES,
etag_source=etag_seed)
Example 2 โ verifying the headers in tests
from fastapi.testclient import TestClient
def test_features_are_cacheable(client: TestClient):
response = client.get("/features?limit=5")
assert response.headers["cache-control"].startswith("public, max-age=")
assert "etag" in response.headers
assert "Accept" in response.headers.get("vary", "")
def test_conditional_request_returns_304(client: TestClient):
first = client.get("/features?limit=5")
second = client.get("/features?limit=5",
headers={"If-None-Match": first.headers["etag"]})
assert second.status_code == 304
assert second.content == b""
assert second.headers["etag"] == first.headers["etag"]
def test_different_queries_have_different_etags(client: TestClient):
a = client.get("/features?limit=5").headers["etag"]
b = client.get("/features?limit=10").headers["etag"]
assert a != b, "the ETag must depend on the query, or caches serve wrong results"
def test_private_endpoints_are_not_shared(client: TestClient):
response = client.get("/my/features", headers={"Authorization": "Bearer x"})
assert "private" in response.headers["cache-control"]
The third test is the one that catches the dangerous bug: an ETag that does not depend on the query means a cache serves one query's response for another's URL.
Example 3 โ measuring the effect on your own service
import asyncio
import statistics
import time
import httpx
async def cache_benefit(url, n=200, concurrency=10):
async with httpx.AsyncClient(timeout=60) as client:
head = await client.get(url)
etag = head.headers.get("etag")
if not etag:
print("no ETag โ nothing to measure")
return
async def run(headers):
limits = httpx.Limits(max_connections=concurrency)
async with httpx.AsyncClient(limits=limits, timeout=60) as client:
await client.get(url, headers=headers)
semaphore = asyncio.Semaphore(concurrency)
latencies = []
async def one():
async with semaphore:
started = time.perf_counter()
response = await client.get(url, headers=headers)
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 (responses[0].status_code, n / wall,
statistics.median(latencies) * 1000,
int(responses[0].headers.get("content-length", 0)))
cold = await run({})
warm = await run({"If-None-Match": etag})
for label, (status, rps, p50, size) in (("full body", cold), ("conditional", warm)):
print(f"{label:12} {status} {rps:7.1f} rps p50 {p50:6.1f} ms {size:,} bytes")
full body 200 100.2 rps p50 94.6 ms 340,521 bytes
conditional 304 1,191.0 rps p50 6.2 ms 0 bytes
Explanation
Why the ETag should depend on the query, not just the body
If the ETag is computed from the body, it is automatically query-dependent โ different queries produce different bodies. If it is computed from a data version alone, two different queries share an ETag, and a cache keyed on the URL plus the ETag can serve one for the other.
The safe rule: whatever the ETag is derived from must include everything that changes the response โ the data version and every parameter that affects the output.
Why immutable requires a versioned URL
max-age is bypassed by a reload; immutable is not. That makes it much stronger, and correspondingly dangerous if the content at that URL can change.
Putting the version in the path makes the guarantee true by construction: /tiles/v7/โฆ and /tiles/v8/โฆ are different resources, so neither ever needs invalidating. This also solves the hardest operational problem in tile serving, which is purging a CDN holding millions of objects.
Why Vary bugs are so hard to find
They require a shared cache, two clients with different headers, and the right ordering. Locally there is no cache; in staging there may be one client; in production it happens intermittently and looks like corruption.
The defence is mechanical rather than diagnostic: whenever a response depends on a request header, add that header to Vary at the same moment. A test that asserts Vary contains Accept costs one line.
Why a 304 must repeat the headers
A conditional request that returns 304 refreshes the cached entry's metadata. If the 304 omits Cache-Control, the cache may fall back to a default; if it omits Vary, the entry's keying can change.
The practical consequence is caches that behave differently after a revalidation than before it, which is the kind of intermittent problem that consumes a week. Constructing the headers first and choosing the status second โ as in the helper above โ makes it impossible to get wrong.
Edge cases or notes
- The ETag must cover every parameter that changes the response.
- 304 responses carry headers and no body, and no
Content-Lengthclaiming one. privatefor authenticated responses, or a shared cache may leak between users.Vary: *disables caching; keep the list minimal and specific.- Weak ETags (
W/"โฆ") allow semantically equivalent responses to match โ useful when the encoding varies. immutableonly with a versioned URL.- Gzip and cache compound โ 2.87ร compression on top of removed requests.
- Test the headers, including that different queries produce different ETags.
Internal links
- HTTP caching for spatial data: ETags, max-age and invalidation โ the model behind these headers
- Fixing a spatial API that is slow under load โ caching as the first fix
- Dynamic tile server or static tiles: what to serve โ versioned tile URLs
- How to serve vector tiles from PostGIS with ST_AsMVT โ caching dynamic tiles
- GeoJSON, vector tiles or Parquet: choosing an API response format โ why
Vary: Acceptis needed - How to test a spatial API with pytest and httpx โ the header tests
- Serving spatial data explained: files, features and tiles โ the wider design
- How to serve raster tiles from a COG with TiTiler โ caching a tiler
FAQ
Which cache headers does a spatial API need?
Cache-Control for the freshness window, ETag for cheap revalidation, and Vary for any request header the response depends on.
How much does it help?
Measured on one endpoint: 1,191 requests per second and 6.2 ms on the 304 path, against 100 requests per second and 94.6 ms for generating and sending the body.
How should I compute the ETag?
From the body when it is cheap to produce, or from a data version plus every query parameter when it is not. The second lets the 304 path skip the data entirely.
When is immutable safe?
Only when the URL's content cannot change โ which you guarantee by versioning the path. Then a data update is a new prefix and nothing needs purging.
What goes in Vary?
Every request header the response depends on, usually Accept and Accept-Encoding. Omitting it lets a shared cache serve the wrong format to some clients.
Do 304 responses need headers?
Yes โ the same ETag, Cache-Control and Vary a 200 would have carried, and no body. A 304 refreshes the cache entry's metadata.