How to Set Cache Headers on a Spatial API and Tile Service

On this page

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 megabyte of GeoJSON serialised, compressed and sent again.

Measured on a real FastAPI endpoint — 50 Natural Earth provinces, 1.01 MB of GeoJSON, ten clients at once (Example 3) — the difference between doing that work and not:

                      throughput   p50 latency   bytes sent
generate and send       21.2 rps      473.4 ms      337,521
answer 304             933.3 rps        9.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=())
Deriving an ETag from a data version so the 304 path never touches the data. the cheap ETaglayer versionchanges when data does+ query parametersbbox, limit, formathash → ETagbefore any workmatch? 304no query, noserialisationHashing the body is correct and requires producing the body first spatialworkflow.io
This is what turns 473 ms into 9 ms rather than merely saving the transfer.

What you get — from The data used on this page

4596

A policy per endpoint, cheap ETags, honest immutable

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 473 ms into 9 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 the 50-province response, gzip took 1,010,943 bytes to 337,521 on the wire — a factor of 3.00 (Example 4). With Accept-Encoding in Vary, a cache stores the compressed and uncompressed variants separately and serves each client the right one. Starlette's GZipMiddleware adds Accept-Encoding to Vary itself when it compresses, so with the policy above the header came out as Accept, Accept-Encoding, Accept-Encoding — harmless, since a repeated field name means the same thing, but worth de-duplicating if a test compares the header exactly.

What to assert about cache headers in a test suite. four assertions, one dangerousCache-Control is present and public/private as intendedper endpointa conditional request returns 304 with no bodyand repeats the ETagdifferent queries have different ETagsthe dangerous oneVary lists every header the response depends onAccept, Accept-Encodingtrusting the CDN to work it outits defaults are conservativeAn ETag that ignores the query lets a cache serve one query’s answer for another spatialworkflow.io
These are four lines of test each and they catch bugs that only appear in production.

Headers applied, tested and measured

The data used on this page

provinces.gpkg is Natural Earth's 1:10m admin-1 states and provinces layer: 4,596 polygons with 121 attribute columns. The timings in Example 3 are from Example 1's app served by uvicorn on the same 24-thread machine as the client, with the load average printed beside them.

import os

import geopandas as gpd

if not os.path.exists("provinces.gpkg"):
    gpd.read_file("https://naciscdn.org/naturalearth/10m/cultural/ne_10m_admin_1_states_provinces.zip") \
       .to_file("provinces.gpkg")
print(len(gpd.read_file("provinces.gpkg")))
4596

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"]
client = TestClient(app)
for test in (test_features_are_cacheable, test_conditional_request_returns_304,
             test_different_queries_have_different_etags):
    test(client)
    print("passed:", test.__name__)
passed: test_features_are_cacheable
passed: test_conditional_request_returns_304
passed: test_different_queries_have_different_etags

test_private_endpoints_are_not_shared needs a /my/features route, which Example 1 does not have; it is the shape the test takes once there is one. 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")


# here: Example 1's app, served on a local port
import os, threading, uvicorn
server = uvicorn.Server(uvicorn.Config(app, port=8767, log_level="warning"))
threading.Thread(target=server.run, daemon=True).start()
while not server.started:
    time.sleep(0.1)
print(f"load average {os.getloadavg()[0]:.1f}")
asyncio.run(cache_benefit("http://127.0.0.1:8767/features?limit=50"))
load average 0.4
full body    200     21.2 rps  p50  473.4 ms  337,521 bytes
conditional  304    933.3 rps  p50    9.2 ms  0 bytes

The full-body size is what went over the wire: httpx asks for gzip, and Example 1's middleware compresses anything over 1,000 bytes.

Example 4 — what gzip does to that response

plain = client.get("/features?limit=50", headers={"Accept-Encoding": "identity"})
packed = httpx.get("http://127.0.0.1:8767/features?limit=50", headers={"Accept-Encoding": "gzip"})
wire = int(packed.headers["content-length"])
print(f"{len(plain.content):,} bytes plain, {wire:,} gzipped on the wire ({len(plain.content) / wire:.2f}x); "
      f"Vary: {packed.headers['vary']}")
server.should_exit = True
1,010,943 bytes plain, 337,521 gzipped on the wire (3.00x); Vary: Accept, Accept-Encoding, Accept-Encoding

Query-dependent ETags and complete 304s

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.

Why immutable requires a versioned URL. the strongest cache directive, and its preconditionimmutable without versioning✕a reload does not revalidate✕stale content is unfixable✕purging a CDN is slow and partial✕the strongest directive, misappliedimmutable with /v7/✓the URL cannot change content✓a data update becomes /v8/✓nothing needs purging✓old URLs simply stop being requestedVersioning the path makes the guarantee true by construction spatialworkflow.io
This also solves the hardest operational problem in tile serving.

Parameters, authentication and Vary: *

  • 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 — 3.00× 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: 933 requests per second and 9.2 ms on the 304 path, against 21 requests per second and 473 ms for generating and sending a 1 MB 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.