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.

Zoom thresholds are measured, not chosenSource data is profiled for feature density, density is mapped to per-layer zoom thresholds, Tippecanoe builds the tileset, the output is validated against the size budget, and failures feed back into the thresholds.PLANNING LOOPProfilefeatures per km2per layer, per regionSet thresholdsminzoom / maxzoomper layerBuildtippecanoe -z -ZValidatemax tile bytesfeature dropsfailures return to thethresholds
The loop closes. A threshold that produces a 700 KB tile is wrong, and the validation step is what tells you so before a reader does.

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:

bash
ogr2ogr -f GeoJSON -t_srs EPSG:4326 output.geojson input.gpkg

Core Concept: The Zoom–Density Relationship

Tile size against zoom for three densities of the same layerMean tile size from zoom 8 to zoom 16 for rural, suburban and dense urban extents of one building layer, against the 500 KB ceiling.MEASURED7005253501750z8z10z12z13z14z15z16zoom level500 KB ceilingDense urbanSuburbanRural
Each curve peaks and then falls: past the peak, the same features spread across four times as many tiles. The peak zoom is where the budget is decided — and it is different for every density.

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:

python
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

Which layers appear at which zooms, and the signal that sets each boundSix layer types with their typical minimum and maximum zoom and the density measurement that justifies each threshold.THRESHOLD TABLEmin zoommax zoomsignal that sets itCoastlines and borders08visible at continentscaleMajor roads514network stays legibleat z5Minor roads1116unreadable below z11Building footprints1316peak tile size lands atz14Address points1516one label per buildingLandcover polygons412detail stops payingabove z12
The right-hand column is the point: every bound here is a measurement, and a build that cannot name the measurement behind a threshold is guessing.

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:

python
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:

python
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%:

bash
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:

python
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.

Zoom Thresholds Are a Cartographic Decision, Not Only a Budget One

The measurements on this page answer where a tile stops fitting. They do not answer where a feature stops being useful, and the two limits are usually different — the cartographic one comes first.

Minor roads are a clear example. A z9 tile can carry them within budget for most cities, and at z9 they render as an indistinguishable grey mesh that obscures the arterial network the reader is actually looking for. The correct minzoom is the zoom at which the layer starts helping, which is a judgement about legibility, and the budget only enters when it is tighter than that judgement.

This is worth being deliberate about because budget-driven thresholds and legibility-driven thresholds fail differently. A threshold set by the budget produces a map that is technically complete and visually unreadable at some zooms. A threshold set by legibility produces a map that is readable everywhere and occasionally omits something a power user wanted. The second failure is recoverable through an optional layer or a zoom hint; the first is a redesign.

In practice the workflow is to set thresholds by legibility first, then run the density profiling in this section to find where the budget is tighter, and treat every place the budget wins as a signal to reduce attributes or simplify rather than to cut a zoom the reader needs.

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:

bash
# 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:

json
{
  "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

FAQ

How do I choose a maximum zoom?

Measure feature density on the densest region, convert to features per tile at each candidate zoom, multiply by measured bytes per feature, and take the deepest zoom that stays under 500 KB.

Should every layer share the tileset’s zoom range?

No, and giving them all the full range is the most common source of wasted tiles. Each layer should carry the narrowest range at which it is both legible and affordable.

Why does tile size fall again above a certain zoom?

Because the same features spread across four times as many tiles at each level. Every layer has a peak zoom where tiles are largest, and that peak is where the budget is actually decided.

Is it better to generate deeper or to overzoom?

Overzoom whenever the source has no additional detail to reveal. Each generated level costs roughly four times the storage of the one below it, for geometry that was already simplified away.

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 --simplification tolerance 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.
Next reading Calculating Optimal Max Zoom for Urban Datasets Next reading Choosing minzoom for Sparse Datasets Next reading Overzooming vs Generating Higher Max-Zoom Tiles