Dynamic Tile Server or Static Tiles: What to Serve
On this page
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.
Count the tiles, then ask how often data changes
1. Count the tiles before deciding
A full pyramid to zoom z contains
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 the Netherlands to zoom 14 it is 50,583 tiles (Example 1): 42 minutes of cutting at an assumed 50 ms a tile, once. A bigger country, or a deeper zoom, multiplies that fast.
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 with one worker: a response serialised on every request ran at 23.1 requests per second, the same body stored once at 586.4 requests per second, and a conditional request answered by 304 at 1,318.3 requests per second and 5.3 ms.
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.
Pyramid arithmetic and a cached dynamic endpoint
The data used on this page
provinces.gpkg is Natural Earth's 1:10m admin-1 states and provinces layer, 4,596 polygons. The setup turns it into a small vector-tile renderer, build_mvt(z, x, y), which clips the layer to a tile, keeps each province's name and returns an encoded Mapbox Vector Tile β or None for an empty tile. The area is the Netherlands, 3.31β7.23Β° E, 50.75β53.56Β° N.
import os
import geopandas as gpd
import mapbox_vector_tile
import morecantile
import shapely
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")
MERCATOR = gpd.read_file("provinces.gpkg")[["name", "geometry"]].to_crs(3857)
MERCATOR.sindex
TMS = morecantile.tms.get("WebMercatorQuad")
NETHERLANDS = (3.31, 50.75, 7.23, 53.56)
def build_mvt(z, x, y, extent=4096):
b = TMS.xy_bounds(morecantile.Tile(x, y, z))
clipped = MERCATOR.clip((b.left, b.bottom, b.right, b.top))
clipped = clipped[~clipped.is_empty]
if clipped.empty:
return None
sx, sy = extent / (b.right - b.left), extent / (b.top - b.bottom)
features = [{"geometry": shapely.affinity.affine_transform(g, [sx, 0, 0, sy, -b.left * sx, -b.bottom * sy]).wkt,
"properties": {"name": n}} for g, n in zip(clipped.geometry, clipped["name"])]
return mapbox_vector_tile.encode([{"name": "provinces", "features": features}],
default_options={"extents": extent})
print(f"{len(MERCATOR):,} provinces; tile 8/131/84: {len(build_mvt(8, 131, 84)):,} bytes")
4,596 provinces; tile 8/131/84: 1,686 bytes
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 / 60:8.1f} min "
f"at {seconds_per_tile * 1000:.1f} ms per tile")
print(f" disk {total * bytes_per_tile / 1e6:8.1f} MB "
f"at {bytes_per_tile:,} bytes per tile")
return total
for z in (6, 10, 13, 14, 18):
print(f"whole world to zoom {z}: {(4 ** (z + 1) - 1) // 3:,} tiles")
pyramid_report(NETHERLANDS) # 50 ms and 15 kB a tile, assumed
whole world to zoom 6: 5,461 tiles
whole world to zoom 10: 1,398,101 tiles
whole world to zoom 13: 89,478,485 tiles
whole world to zoom 14: 357,913,941 tiles
whole world to zoom 18: 91,625,968,981 tiles
zoom tiles
10 168
11 648
12 2,438
13 9,450
14 37,800
total 50,583
cutting time 42.2 min at 50.0 ms per tile
disk 758.7 MB at 15,000 bytes per tile
Forty-two minutes and 759 MB for the Netherlands to zoom 14 at the assumed 50 ms and 15 kB a tile β a reasonable job to run nightly, and an unreasonable one to run on every deployment. Those per-tile figures are assumptions; Example 4 measures them for a real layer.
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) or b"" # 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})
from fastapi.testclient import TestClient
client = TestClient(app)
first = client.get("/tiles/v7/8/131/84.mvt")
again = client.get("/tiles/v7/8/131/84.mvt", headers={"If-None-Match": first.headers["etag"]})
print(first.status_code, f"{len(first.content):,} bytes", first.headers["cache-control"])
print(again.status_code, f"{len(again.content)} bytes")
print(client.get("/tiles/live/8/131/84.mvt").headers["cache-control"], client.get("/tiles/v7/17/0/0.mvt").status_code)
200 1,686 bytes public, max-age=31536000, immutable
304 0 bytes
public, max-age=60 404
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
import shutil
shutil.rmtree("tiles", ignore_errors=True)
written, _ = cut_pyramid(build_mvt, NETHERLANDS, 5, 10, "tiles")
cells = sum((deg2tile(NETHERLANDS[2], NETHERLANDS[1], z)[0] - deg2tile(NETHERLANDS[0], NETHERLANDS[3], z)[0] + 1)
* (deg2tile(NETHERLANDS[2], NETHERLANDS[1], z)[1] - deg2tile(NETHERLANDS[0], NETHERLANDS[3], z)[1] + 1)
for z in range(5, 11))
print(f"{cells:,} tiles in the box, {written:,} with any province in them, {cells - written:,} empty and not written")
zoom 5: 1 written, 0 skipped
zoom 6: 5 written, 0 skipped
zoom 7: 9 written, 0 skipped
zoom 8: 23 written, 0 skipped
zoom 9: 63 written, 0 skipped
zoom 10: 193 written, 0 skipped
242 tiles in the box, 193 with any province in them, 49 empty and not written
Not writing empty tiles is worth more than it looks. Over the Netherlands' box, 49 of the 242 tiles from zoom 5 to 10 are open sea with no province in them, a fifth of the pyramid; a missing file plus a 404 is cheaper than storing them.
Example 4 β the cost of one tile, measured
import random
random.seed(1)
samples = []
for z in (10, 12, 14):
x0, y0 = deg2tile(NETHERLANDS[0], NETHERLANDS[3], z)
x1, y1 = deg2tile(NETHERLANDS[2], NETHERLANDS[1], z)
samples += [(z, random.randint(x0, x1), random.randint(y0, y1)) for _ in range(40)]
time_tiles(build_mvt, samples)
sizes = [len(t) for t in (build_mvt(*s) for s in samples) if t]
per_tile = sorted(sizes)[len(sizes) // 2]
print(f"{len(sizes)} of {len(samples)} sampled tiles non-empty; median size {per_tile:,} bytes")
started = time.perf_counter()
for s in samples:
build_mvt(*s)
mean_s = (time.perf_counter() - started) / len(samples)
pyramid_report(NETHERLANDS, seconds_per_tile=mean_s, bytes_per_tile=per_tile)
median 1.3 ms p95 1.7 ms worst 1.9 ms
90 of 120 sampled tiles non-empty; median size 72 bytes
zoom tiles
10 168
11 648
12 2,438
13 9,450
14 37,800
total 50,583
cutting time 1.1 min at 1.3 ms per tile
disk 3.6 MB at 72 bytes per tile
How fast a pyramid grows
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 takes the total from 89.5 million to 357.9 million tiles, four times as many.
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 23.1 requests per second against 1,318 β 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.
Empty tiles, versioned URLs and range requests
- The Netherlands to zoom 14 is 50,583 tiles; each extra level roughly quadruples 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?
How long does cutting a pyramid take?
For the Netherlands to zoom 14, 50,583 tiles: 42 minutes at an assumed 50 ms a tile. The provinces layer measured in Example 4 rendered in 1.3 ms a tile, about a minute for the lot; a dense layer such as buildings costs far more per tile. Time your own renderer before deciding.
Is a dynamic tile server slow?
Only without a cache. Measured on an HTTP service, answering a conditional request ran at 1,318 requests per second against 23.1 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.