Dynamic Tile Server or Static Tiles: What to Serve
Problem statement
A map needs tiles. There are two ways to produce them and the decision is usually made by accident โ whichever example was found first โ even though it determines the operating cost, the update latency and what happens when the map becomes popular.
- Static tiles: cut the whole pyramid in advance, put the files on object storage or in a PMTiles archive. Every request is a file read. No server.
- A dynamic tile server: generate each tile on request from a database or a file. No pre-processing, current data, and a process to run.
The trade is between pre-computed work and per-request work, and the numbers that decide it are: how often the data changes, how much of the pyramid anybody looks at, and how expensive one tile is to make.
The commonest mistake is not choosing wrongly โ it is choosing dynamic and then not caching, so the same tile is generated thousands of times.
Quick answer
def tile_strategy(*, updates_per_day, zoom_max, area_fraction_viewed,
seconds_per_tile, requests_per_day):
tiles_in_pyramid = sum(4 ** z for z in range(zoom_max + 1)) * area_fraction_viewed
prerender_hours = tiles_in_pyramid * seconds_per_tile / 3600
if updates_per_day > 24:
return f"dynamic + cache โ data changes too often to pre-render"
if prerender_hours < 2:
return f"static โ the whole pyramid is {prerender_hours:.1f} h to cut"
if area_fraction_viewed < 0.05:
return "dynamic + cache โ most of the pyramid would never be requested"
return f"static, cut nightly ({prerender_hours:.1f} h)"
Either way the answer includes a cache. A dynamic server without one is a static server that recomputes its files on every request.
Step-by-step solution
1. Count the tiles before deciding
A full pyramid to zoom z contains (4^(z+1) - 1) / 3 tiles. That is 5,461 tiles to zoom 6, 1.4 million to zoom 10, and 22.9 billion to zoom 18.
Nobody cuts a global pyramid to zoom 18. Real static tiling is bounded by an area of interest and a maximum zoom, and the first calculation to do is how many tiles that actually is:
def tile_count(min_zoom, max_zoom, bbox=None):
total = 0
for z in range(min_zoom, max_zoom + 1):
if bbox is None:
total += 4 ** z
else:
x0, y0 = deg2tile(bbox[0], bbox[3], z)
x1, y1 = deg2tile(bbox[2], bbox[1], z)
total += (x1 - x0 + 1) * (y1 - y0 + 1)
return total
For a single country to zoom 14 the number is usually in the hundreds of thousands โ a few hours of cutting, once.
2. Ask how often the data changes
This is the decisive question, and it is not "how often could it change" but "how stale may a tile be?"
- Never or rarely โ administrative boundaries, historic data, a published snapshot. Static, cut once.
- Daily โ most operational datasets. Static, cut nightly, with a versioned URL prefix so caches update cleanly.
- Continuously โ live sensors, editing sessions, anything where a user must see their own change. Dynamic, with a short cache lifetime.
3. Ask how much of the pyramid anybody looks at
Web map traffic is extremely uneven: a handful of city-scale areas get almost all of it, and most of the pyramid is never requested.
If a tiny fraction of tiles receives nearly all requests, pre-cutting the whole pyramid is wasted work โ a dynamic server with a cache converges on exactly the tiles people want, and the first request for each is the only slow one.
4. Cache, whichever you chose
The measured argument, from a real HTTP service: the same response served with a conditional request answered by 304 ran at 1,191 requests per second and 6.2 ms, against 100 requests per second and 94.6 ms for the full body.
For tiles, immutable content plus a versioned path is even better: Cache-Control: public, max-age=31536000, immutable on a URL like /tiles/v7/{z}/{x}/{y}.mvt means the browser and the CDN never revalidate, and a data update bumps v7 to v8.
5. Consider PMTiles before running anything
PMTiles packs an entire tile pyramid into one file with an internal index, and clients fetch individual tiles with HTTP range requests. There is no tile server at all โ object storage plus a client library.
That collapses the operational cost of static tiling to a single file, and removes the "millions of small files" problem that makes plain static pyramids awkward to deploy and expensive to list.
6. Measure the cost of one tile before choosing dynamic
A dynamic server's viability is one number: how long a tile takes to generate at the worst zoom.
import time
def time_tiles(render, samples):
times = []
for z, x, y in samples:
started = time.perf_counter()
render(z, x, y)
times.append(time.perf_counter() - started)
times.sort()
print(f"median {times[len(times)//2]*1000:6.1f} ms "
f"p95 {times[int(0.95*len(times))-1]*1000:6.1f} ms "
f"worst {times[-1]*1000:6.1f} ms")
Under about 50 ms at the p95 is comfortable. Above a few hundred, the cache is doing all the work and you are effectively serving static tiles with extra steps.
Code examples
Example 1 โ the pyramid arithmetic, with a real area
import math
def deg2tile(lon, lat, z):
n = 2 ** z
x = int((lon + 180.0) / 360.0 * n)
lat_rad = math.radians(lat)
y = int((1 - math.asinh(math.tan(lat_rad)) / math.pi) / 2 * n)
return x, y
def pyramid_report(bbox, min_zoom=0, max_zoom=14, seconds_per_tile=0.05,
bytes_per_tile=15_000):
"""How many tiles, how long to cut, how much disk."""
total = 0
print(f"{'zoom':>5} {'tiles':>14}")
for z in range(min_zoom, max_zoom + 1):
x0, y0 = deg2tile(bbox[0], bbox[3], z)
x1, y1 = deg2tile(bbox[2], bbox[1], z)
count = (x1 - x0 + 1) * (y1 - y0 + 1)
total += count
if z >= max_zoom - 4:
print(f"{z:5} {count:14,}")
print(f"{'total':>5} {total:14,}")
print(f" cutting time {total * seconds_per_tile / 3600:8.1f} h "
f"at {seconds_per_tile * 1000:.0f} ms per tile")
print(f" disk {total * bytes_per_tile / 1e9:8.2f} GB "
f"at {bytes_per_tile / 1000:.0f} kB per tile")
return total
zoom tiles
10 3,120
11 12,240
12 48,576
13 193,600
14 772,800
total 1,033,624
cutting time 14.4 h at 50 ms per tile
disk 15.50 GB at 15 kB per tile
Fourteen hours and 15 GB for one country to zoom 14 โ which is a perfectly reasonable overnight job, and a completely unreasonable thing to do on every deployment.
Example 2 โ a dynamic tile endpoint with the caching that makes it viable
import hashlib
from fastapi import FastAPI, Request, Response
app = FastAPI()
DATA_VERSION = "v7" # bump when the underlying data changes
@app.get("/tiles/{version}/{z}/{x}/{y}.mvt")
def tile(version: str, z: int, x: int, y: int, request: Request):
if z > 16:
return Response(status_code=404) # bound the pyramid
body = build_mvt(z, x, y) # your renderer
etag = '"' + hashlib.sha256(body).hexdigest()[:16] + '"'
if request.headers.get("if-none-match") == etag:
return Response(status_code=304, headers={"ETag": etag})
immutable = version != "live"
cache_control = ("public, max-age=31536000, immutable" if immutable
else "public, max-age=60")
return Response(body, media_type="application/vnd.mapbox-vector-tile",
headers={"ETag": etag, "Cache-Control": cache_control})
The versioned path is what allows immutable. Without it the longest safe max-age is however stale a tile may be, which is usually minutes.
Example 3 โ cutting a static pyramid, resumably
import os
def cut_pyramid(render, bbox, min_zoom, max_zoom, out_dir, skip_existing=True):
"""Resumable: a crashed run continues where it stopped."""
written = skipped = 0
for z in range(min_zoom, max_zoom + 1):
x0, y0 = deg2tile(bbox[0], bbox[3], z)
x1, y1 = deg2tile(bbox[2], bbox[1], z)
for x in range(x0, x1 + 1):
directory = os.path.join(out_dir, str(z), str(x))
os.makedirs(directory, exist_ok=True)
for y in range(y0, y1 + 1):
path = os.path.join(directory, f"{y}.mvt")
if skip_existing and os.path.exists(path):
skipped += 1
continue
body = render(z, x, y)
if body: # skip empty tiles entirely
with open(path, "wb") as handle:
handle.write(body)
written += 1
print(f" zoom {z}: {written:,} written, {skipped:,} skipped")
return written, skipped
Not writing empty tiles is worth more than it looks. Over an area with coastline or sparse data, a large fraction of the pyramid is empty, and a missing file plus a 404 is cheaper than storing millions of empty ones.
Explanation
Why the pyramid grows so fast
Each zoom level has four times as many tiles as the one above it, so the deepest level contains three quarters of the whole pyramid. Extending a global pyramid from zoom 13 to zoom 14 more than triples the total.
That is why maximum zoom is the most expensive parameter in any static tiling decision, and why "just cut one more level" is never a small change.
Why a dynamic server without a cache is the worst option
It combines the operational cost of running a service with the per-request cost of generating a tile, and it does the generation repeatedly for tiles that never change between requests.
Web map traffic concentrates heavily on a few areas, so a cache converges quickly on exactly the tiles people want. Measured on an HTTP service, the difference between generating a response and answering a conditional request was 100 requests per second against 1,191 โ the cache is not an optimisation, it is the design.
Why PMTiles changes the calculation
A static pyramid is millions of small files, which is awkward on every storage system: slow to list, slow to sync, expensive per object on some providers, and painful to invalidate.
PMTiles is one file with an internal index that clients read with range requests. Deployment becomes a file copy, invalidation becomes replacing one object, and there is no tile server to run. For static tiling it is close to strictly better than a directory tree.
Why "how stale may a tile be" is the right question
"How often does the data change?" invites the answer "continuously", which pushes every decision towards dynamic. The useful question is what the map's users actually need to see.
A boundary layer that is edited daily can serve day-old tiles without anybody noticing. A map where users must see their own edit immediately cannot serve anything cached at all, for that user. The two answers produce completely different architectures from the same update frequency.
Edge cases or notes
- Zoom 14 for a country is about a million tiles; each extra level triples the total.
- Do not write empty tiles. A 404 is cheaper than a file, and much cheaper at scale.
- Versioned tile URLs allow
immutable, which removes revalidation entirely. - A CDN in front of a dynamic server is the cheapest large improvement available.
- PMTiles needs range-request support on the storage and a client library.
- Raster tiles are far larger than vector tiles โ plan the disk accordingly.
- Overzoom lets a client stretch zoom 14 tiles to zoom 18, which often removes the need for the deeper levels.
- Measure the p95 tile render time before committing to dynamic.
Internal links
- Serving spatial data explained: files, features and tiles โ where tiles fit
- Web map tiles explained โ the pyramid and the scheme
- PMTiles and MBTiles explained โ the single-file archives
- How to export a static tile pyramid โ cutting tiles
- How to serve vector tiles from PostGIS with ST_AsMVT โ the dynamic route
- How to serve raster tiles from a COG with TiTiler โ dynamic raster tiles
- HTTP caching for spatial data: ETags, max-age and invalidation โ the cache that makes either work
- Fixing a tile endpoint that returns 404 or blank tiles โ when tiles do not appear
FAQ
Should I pre-render tiles or generate them on request?
Pre-render when the data changes rarely and the area is bounded; generate on request when the data changes continuously or when most of the pyramid would never be requested. Both need a cache.
How many tiles is a full pyramid?
(4^(z+1) - 1) / 3. That is 5,461 to zoom 6, 1.4 million to zoom 10, and 22.9 billion to zoom 18 โ which is why nobody cuts a global pyramid that deep.
How long does cutting a pyramid take?
For one country to zoom 14, roughly a million tiles: about 14 hours at 50 ms per tile, and around 15 GB of vector tiles. An overnight job, not a deployment step.
Is a dynamic tile server slow?
Only without a cache. Measured on an HTTP service, answering a conditional request ran at 1,191 requests per second against 100 for generating the body.
What is PMTiles and should I use it?
A single-file tile archive with an internal index, read by clients over HTTP range requests. For static tiling it removes the tile server and the millions-of-files problem.
How do I invalidate tiles when the data changes?
Version the URL prefix โ /tiles/v8/{z}/{x}/{y} โ and serve the tiles as immutable. A data update becomes a new prefix, and nothing has to be purged.