Zoom Level Optimization Strategies
Zoom level configuration determines which features appear at which map scales, how large each tile grows in bytes, and how much work the client GPU must do on every pan or pinch. Get the range wrong and you either bloat low-zoom tiles with unrenderable detail or waste high-zoom storage on geometry that never reaches the viewport. This guide walks through a repeatable, data-driven workflow — from spatial profiling through Tippecanoe generation to CDN delivery — so every zoom threshold is justified by the characteristics of the source data, not by convention.
Prerequisites
Confirm all of the following before running any generation commands:
| Requirement | Minimum version / state |
|---|---|
tippecanoe |
2.x (Felt fork) — supports --extend-zooms-if-still-dropping |
geopandas |
0.13+ with pyogrio backend |
pmtiles CLI |
1.x — for post-generation inspection |
| Input CRS | EPSG:4326 geographic (Tippecanoe projects to EPSG:3857 internally) |
| Storage target | Local NVMe for generation; S3 / GCS / Cloudflare R2 for delivery |
| Validation client | MapLibre GL JS 3.x or a headless tile inspector |
Inputs may arrive as .geojson, .gpkg, .shp, or .geoparquet. Normalize to EPSG:4326 before profiling:
ogr2ogr -f GeoJSON -t_srs EPSG:4326 output.geojson input.gpkg
Core Concept: The Zoom–Density Relationship
Each zoom level doubles the tile count per axis (2^z × 2^z tiles covering the world). A feature that occupies one tile at z10 may span four tiles at z11. The critical constraint from the Mapbox Vector Tile Specification is that each tile operates on a 4096×4096 internal coordinate grid — coordinates are quantized to integers on that grid. The practical consequence: pushing max-zoom higher than your data’s meaningful resolution wastes storage without adding visible detail, while setting min-zoom too low forces Tippecanoe to wedge thousands of features into a single low-resolution tile.
Key parameters that interact with zoom configuration:
| Flag | Effect | Typical range |
|---|---|---|
--minimum-zoom / -Z |
Lowest zoom level generated | 0–5 for global layers |
--maximum-zoom / -z |
Highest zoom level generated | 10–16 for most datasets |
--maximum-zoom=g |
Tippecanoe auto-detects max zoom from data | Safe starting point |
--extend-zooms-if-still-dropping |
Pushes max zoom up if features are still culled | Always combine with a hard cap |
--drop-densest-as-needed |
Removes overlapping features to hit tile size budget | Preferred over --drop-fraction-as-needed |
--simplification |
Douglas-Peucker tolerance (tile coordinate units) | 1–10; higher = fewer vertices |
Understanding how geometry simplification algorithms interact with zoom level selection is critical — the tolerance applied by --simplification is measured in tile coordinate units, not meters, so its visual effect shifts at every zoom level.
Step-by-Step Implementation
Step 1 — Profile Feature Density and Spatial Extent
Hardcoding zoom ranges without spatial profiling produces either bloated low-zoom tiles or invisible high-zoom detail. Measure bounding area, feature count, and average vertex complexity before setting any thresholds:
import geopandas as gpd
import numpy as np
from pathlib import Path
def profile_layer(path: Path) -> dict:
gdf = gpd.read_file(path)
bbox = gdf.total_bounds # [minx, miny, maxx, maxy] in source CRS
# Approximate area in km² (valid for EPSG:4326 inputs at mid-latitudes)
area_km2 = (bbox[2] - bbox[0]) * (bbox[3] - bbox[1]) * 111.32 ** 2
def vertex_count(geom):
if geom is None:
return 0
return sum(len(p.coords) for p in getattr(geom, "geoms", [geom])
if hasattr(p, "coords"))
counts = [vertex_count(g) for g in gdf.geometry]
return {
"bounds": bbox.tolist(),
"crs": str(gdf.crs),
"feature_count": len(gdf),
"area_km2": round(area_km2, 2),
"density_per_km2": round(len(gdf) / max(area_km2, 0.01), 2),
"avg_vertices": int(np.mean(counts)) if counts else 0,
"p95_vertices": int(np.percentile(counts, 95)) if counts else 0,
}
Verify: CRS must be EPSG:4326. If profile["crs"] shows a projected system, re-project before proceeding.
Step 2 — Map Density Profiles to Per-Layer Zoom Thresholds
A single global max-zoom applied to every layer is an anti-pattern. Different layer types warrant different ceilings:
| Layer type | Typical min-zoom |
Typical max-zoom |
Density signal |
|---|---|---|---|
| Continental boundaries, ocean | 0 | 6 | < 10 features/km² |
| Country/state admin boundaries | 2 | 8 | 10–100 features/km² |
| Road network (primary + secondary) | 5 | 12 | 100–1,000 features/km² |
| Urban parcels, building footprints | 12 | 16 | > 1,000 features/km² |
| Survey-grade infrastructure | 14 | 18 | High vertex density (p95 > 200) |
For dense metropolitan datasets — where a single z15 tile can contain 1,200+ vertices across 85 overlapping parcels — use the ground-resolution formula to validate the ceiling. The dedicated page on calculating optimal max zoom for urban datasets covers the penalty framework in full.
Encode the mapping as a typed structure rather than ad-hoc constants:
from dataclasses import dataclass
@dataclass
class LayerZoomConfig:
name: str
min_zoom: int
max_zoom: int
simplification: float
def zoom_config_from_profile(layer_name: str, profile: dict) -> LayerZoomConfig:
d = profile["density_per_km2"]
v = profile["p95_vertices"]
if d < 10:
z_min, z_max, simp = 0, 6, 4.0
elif d < 100:
z_min, z_max, simp = 2, 8, 3.0
elif d < 1000:
z_min, z_max, simp = 5, 12, 2.0
elif d < 10000:
z_min, z_max, simp = 10, 14, 1.0
else:
z_min, z_max, simp = 12, 16, 0.5
# Raise ceiling for high-vertex datasets
if v > 200:
z_max = min(z_max + 1, 18)
return LayerZoomConfig(layer_name, z_min, z_max, simp)
Step 3 — Generate Tiles with Tippecanoe
Wrap tippecanoe in a Python subprocess with explicit parameters. Avoid relying on default values for anything that affects tile size or zoom range:
import subprocess
import logging
from pathlib import Path
def generate_tiles(
input_path: Path,
output_pmtiles: Path,
cfg: LayerZoomConfig,
) -> None:
cmd = [
"tippecanoe",
f"--minimum-zoom={cfg.min_zoom}",
f"--maximum-zoom={cfg.max_zoom}",
"--drop-densest-as-needed", # honor 500 KB tile budget
"--extend-zooms-if-still-dropping", # cap at z_max, but document if hit
f"--simplification={cfg.simplification}",
"--no-tile-compression", # decompress for inspection; re-compress via pmtiles
f"--layer={cfg.name}",
f"--output={output_pmtiles}",
"--force",
str(input_path),
]
logging.info("tippecanoe: %s", " ".join(cmd))
result = subprocess.run(cmd, capture_output=True, text=True, check=False)
if result.returncode != 0:
raise RuntimeError(f"tippecanoe failed:\n{result.stderr}")
logging.info("Generated %s", output_pmtiles)
Watch stderr during generation for dropping messages. If Tippecanoe reports features dropped at z_max, either raise the ceiling or relax the simplification tolerance.
Step 4 — Apply Attribute Filtering
Tile payload grows with every attribute retained per feature. Strip non-rendered columns using --include (whitelist) rather than the default keep-all behaviour. This is the single cheapest size reduction available and typically cuts payload 15–30%:
tippecanoe \
--minimum-zoom=10 \
--maximum-zoom=14 \
--drop-densest-as-needed \
--simplification=1 \
--include=id \
--include=class \
--include=name \
--layer=parcels \
--output=parcels.pmtiles \
--force \
parcels.geojson
The page on dropping unused attributes to reduce tile size covers the -y / --include whitelist pattern in detail, including combining it with --exclude-all to enforce an opt-in rather than opt-out policy.
Step 5 — Validate and Iterate
Check generated tile size before routing to CDN. A .pmtiles archive exceeding 500 KB average tile size (inspect with pmtiles show) signals that min-zoom is too low or --simplification needs to increase:
import subprocess, json
from pathlib import Path
def inspect_pmtiles(path: Path) -> dict:
result = subprocess.run(
["pmtiles", "show", str(path)],
capture_output=True, text=True, check=True
)
# pmtiles show emits JSON to stdout
return json.loads(result.stdout)
def validate_tile_budget(path: Path, max_avg_kb: float = 200.0) -> bool:
meta = inspect_pmtiles(path)
avg_kb = meta.get("average_tile_size", 0) / 1024
if avg_kb > max_avg_kb:
logging.warning(
"%s: avg tile %.1f KB exceeds %.1f KB budget", path.name, avg_kb, max_avg_kb
)
return False
return True
Load the .pmtiles archive into a MapLibre GL JS map and monitor:
- Network payload: DevTools Network tab; individual tile responses should stay below 200 KB at z0–z12 and below 500 KB at z13+.
- Render FPS: Chrome DevTools Performance panel; heavy geometry at mid-zooms (z9–z11) typically causes frame drops during rapid zoom transitions.
- Zoom transition clarity: step through each zoom level; features must remain identifiable at the zoom where they first appear.
Optimization Knobs
Three parameters dominate the quality–size trade-off for most datasets:
| Parameter | Effect on tile size | Effect on fidelity | When to tighten |
|---|---|---|---|
--simplification=N (N higher) |
Reduces vertex count; smaller tiles | Loss of fine geometry at high zoom | Rural polygon layers; p95_vertices > 150 |
--maximum-zoom=z (z lower) |
Fewer zoom levels; smaller archive | Missing street-level detail | When profiling shows features indistinguishable above a threshold zoom |
--drop-densest-as-needed (enabled) |
Keeps tile under budget by culling features | Sparse coverage at lower zooms in dense areas | High-density point datasets (POIs, addresses) |
The trade-off between --simplification values and tile size is non-linear. A tolerance of 2 applied to a polygon layer with 150 average vertices may cut tile size by 40%; the same tolerance on a layer with 20 average vertices does almost nothing. Always re-profile after changing simplification to confirm the actual impact.
For Visvalingam vs Douglas-Peucker trade-offs — area-preserving vs. angle-preserving simplification — Tippecanoe defaults to Douglas-Peucker but supports Visvalingam via --visvalingam.
Integration with Adjacent Pipeline Stages
Output Format: PMTiles over MBTiles
Once Tippecanoe produces a .pmtiles archive, it is self-contained and ready for range-request delivery from any S3-compatible store without a tile server process. The PMTiles specification deep dive explains how the internal cluster index enables single HTTP range requests to retrieve a tile, eliminating the SQLite locking issues that affect MBTiles at scale.
Upload directly to Cloudflare R2 and serve via a Worker or the PMTiles JavaScript library:
# Upload to R2
wrangler r2 object put tiles-bucket/parcels-v3.pmtiles \
--file parcels.pmtiles \
--content-type application/x-protobuf
# MapLibre source reference (client-side)
# "tiles": ["pmtiles://https://pub-<id>.r2.dev/parcels-v3.pmtiles/{z}/{x}/{y}"]
Version the filename (-v3) rather than overwriting. CDN edges hold stale tiles for Cache-Control: max-age seconds; a new filename guarantees instant propagation without a purge API call.
Feeding the Styling Layer
The zoom range you configure during generation must align with the minzoom / maxzoom fields in the MapLibre GL style source object. Mismatches cause invisible layers or phantom data. For MapLibre GL JSON structure conventions, each vector source entry should declare bounds, minzoom, and maxzoom to match the generated archive metadata:
{
"type": "vector",
"url": "pmtiles://https://pub-<id>.r2.dev/parcels-v3.pmtiles",
"minzoom": 10,
"maxzoom": 14
}
Troubleshooting
| Symptom | Probable cause | Diagnosis command | Fix |
|---|---|---|---|
| Empty tiles at high zoom | Input not in EPSG:4326; coordinates out of bounds |
ogrinfo -al -so input.gpkg |
Re-project: ogr2ogr -t_srs EPSG:4326 out.gpkg in.gpkg |
| Tile size spikes at z10–z12 | Unmerged multipolygons; overlapping slivers | pmtiles show tiles.pmtiles — check max_tile_size |
Pre-process with ST_Union or geopandas dissolve before generation |
--extend-zooms-if-still-dropping keeps adding zoom levels |
max-zoom ceiling too low for data density |
Check Tippecanoe stderr for still dropping messages |
Raise max-zoom by 1–2 or increase --simplification |
| Client flickering on zoom transition | Inconsistent feature ordering across zoom levels | MapLibre devtools: tile inspector across adjacent zooms | Add --coalesce-fraction-as-needed; sort by a stable attribute with --order-by=id |
| CDN cache misses after regeneration | Stale ETag or overwritten filename |
curl -I https://cdn.example.com/tiles.pmtiles |
Use versioned filenames; set Cache-Control: max-age=86400, s-maxage=31536000 |
Child Pages
Calculating Optimal Max Zoom for Urban Datasets — derives the ground-resolution-to-density formula that sets a mathematically justified ceiling for dense city layers, including empirical penalty adjustments for vertex clustering, overdraw, and label collision.
Overzooming vs Generating Higher Max-Zoom Tiles — when to let the client overzoom the deepest generated tile versus paying the exponential tile-count cost of a higher generated max-zoom, and the source-maxzoom setting that enables it.
Parent: Vector Tile Architecture & Format Fundamentals
Related
- Geometry Simplification Algorithms — how Tippecanoe’s Douglas-Peucker and Visvalingam modes reduce vertex count at each zoom level, directly affecting what
--simplificationtolerance is safe. - PMTiles Specification Deep Dive — the range-request indexing model that makes cloud-native delivery of zoom-optimized archives efficient without a tile server.
- Attribute Filtering Rules — stripping unused properties during generation, which compounds with zoom optimization to keep tiles within the 500 KB budget.
- Vector vs Raster Tile Tradeoffs — understanding why vector tiles shift zoom-level decisions from the server to the generation pipeline, and when a raster fallback is still warranted.