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 Level Optimization Pipeline Five-stage pipeline: raw geodata is profiled for feature density, thresholds are set per layer, Tippecanoe generates tiles, a validator checks tile size and geometry, and approved tiles are pushed to CDN/PMTiles. Raw Geodata .geojson .geoparquet Density Profiling geopandas bbox · count Threshold Assignment min/max zoom per layer Tippecanoe Generation --drop-densest --simplification Validate & Deliver .pmtiles CDN / R2 iterate on threshold if tile size budget exceeded

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

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

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.

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

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 Overzooming vs Generating Higher Max-Zoom Tiles