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=())
Flow deriving an ETag from a layer version and query parameters before doing work.
This is what turns 94.6 ms into 6.2 ms rather than merely saving the transfer.

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-Control and Vary it would have sent with a 200.
  • A 304 must have no body, and Content-Length must 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.

Checklist of four cache-header assertions plus the anti-pattern of trusting the CDN.
These are four lines of test each and they catch bugs that only appear in production.

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.

Two panels contrasting immutable caching with and without a versioned URL.
This also solves the hardest operational problem in tile serving.

Edge cases or notes

  • The ETag must cover every parameter that changes the response.
  • 304 responses carry headers and no body, and no Content-Length claiming one.
  • private for 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.
  • immutable only 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.

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.