Vector Tile Architecture & Format Fundamentals
Vector tiles transmit raw geometric primitives and semantic attributes to the client rather than pre-rendered pixels, enabling dynamic styling, interactive querying, and resolution-independent rendering. This reference covers the full stack: spatial partitioning math, the Mapbox Vector Tile (MVT) binary encoding, storage containers, production pipeline patterns, and the failure modes that surface in real deployments.
How Vector Tiles Fit the GeoJSON → CDN Pipeline
Every production pipeline moves data through five discrete stages. Understanding where each format and tool sits prevents the category of bugs where data is correct but ends up in the wrong stage.
The critical transition points are Preprocessing → Tile Generation (where coordinate reference system errors cause silent misalignment) and Storage → CDN Delivery (where container format determines whether you need a tile server at all).
Core Architectural Principles
The Global Tiling Scheme
The industry standard combines the Web Mercator projection (EPSG:3857) with quadtree-based spatial partitioning. At zoom level z the world is divided into 2^z × 2^z tiles; each tile is addressed by (z, x, y). The y-axis origin convention differs by scheme:
| Scheme | Y-origin | Used by |
|---|---|---|
| XYZ (Slippy Map) | North-West | OpenStreetMap, Google Maps, MapLibre GL JS |
| TMS | South-West | OGC TMS spec, some GDAL drivers |
The conversion is tms_y = (2^z - 1) - xyz_y. Misapplying this is the most common cause of tiles appearing reflected vertically. Input source data is almost always in geographic coordinates (EPSG:4326); the tiling engine reprojects to EPSG:3857 internally during generation.
Geometry and Attribute Decoupling
Raster tiles bake styling into pixels. Vector tiles separate raw geometry from visual presentation, which means the same tile data can be restyled at runtime without re-fetching. Each .mvt tile contains:
- Geometries: Points, linestrings, and polygons encoded as integer command sequences
- Attributes: Key-value pairs referenced by integer index into per-layer dictionaries
- Layer groupings: Named collections such as
roads,buildings, orwater
This shift moves rendering from the server to the client. The tradeoffs between vector and raster tile delivery are significant enough that the decision affects pipeline architecture, not just format choice.
Spatial Indexing and Feature Generalization
Raw geodata is rarely tile-ready. At low zoom levels, dense datasets — urban building footprints, address points — must be simplified, aggregated, or suppressed to stay within the 500 KB per-tile budget and avoid client-side overdraw. Tippecanoe applies geometry simplification automatically at each zoom level; PostGIS ST_SimplifyPreserveTopology handles this when tiles are generated on-the-fly from a live database. The goal is maximum detail at the highest zoom while maintaining topological correctness across all levels.
The Mapbox Vector Tile Specification
The de facto standard for vector tile encoding is the Mapbox Vector Tile specification v2.1, which uses Protocol Buffers for binary serialization.
Key Specification Fields
| Field | Location | Type | Notes |
|---|---|---|---|
version |
Layer | uint32 | Must be 2 for spec v2.1 compliance |
name |
Layer | string | Layer identifier, e.g. "roads" |
extent |
Layer | uint32 | Tile coordinate grid size; default 4096 |
features |
Layer | repeated Feature | Each feature carries geometry + attribute refs |
keys |
Layer | repeated string | Deduplicated attribute key dictionary |
values |
Layer | repeated Value | Deduplicated attribute value dictionary |
type |
Feature | GeomType | UNKNOWN=0, POINT=1, LINESTRING=2, POLYGON=3 |
geometry |
Feature | repeated uint32 | Encoded command sequence |
tags |
Feature | repeated uint32 | Alternating key-index / value-index pairs |
id |
Feature | optional uint64 | Feature identifier; not guaranteed unique across layers |
Protocol Buffers and Binary Encoding
MVT uses protobuf to serialize tile data. Unlike JSON, protobuf encodes data with numeric field tags and variable-length integers (varints), eliminating key repetition across thousands of features. The binary delta encoding for coordinates typically reduces coordinate payload size 40–60% compared to storing absolute integers. The encoding is described fully in the Protocol Buffers Developer Documentation.
Geometry Command Sequences
Within each feature, geometry is encoded as a flat array of command-and-parameter integers. Three commands are defined:
| Command | ID | Parameters |
|---|---|---|
MoveTo |
1 | (dx, dy) — move cursor, start new sub-geometry |
LineTo |
2 | (dx, dy) repeated — extend current sub-geometry |
ClosePath |
7 | none — close a polygon ring |
Delta encoding applies: each dx/dy is the difference from the previous cursor position, zigzag-encoded as an unsigned integer. Decoding without applying the cumulative delta is a common source of scrambled geometry when writing custom MVT parsers.
Command and count share a single 32-bit integer, which is why a hand-written decoder that reads the whole integer as a command id produces nonsense on the first feature:
ClosePath always carries a count of 1 and consumes no parameters, so a decoder that skips count × 2 parameters unconditionally will desynchronise on the first polygon ring it meets. The MVT encoding internals topic walks the same bytes with a real tile and a Python decoder.
Coordinate Quantization
MVT does not store geographic coordinates. During generation, coordinates are transformed from EPSG:3857 into a local integer grid — 4096 × 4096 units per tile by default (controlled by the extent field). A geographic point at the tile’s north-west corner maps to (0, 0); the south-east corner maps to (4095, 4095). Features that extend slightly beyond a tile boundary are clipped and a buffer is added (typically 64 units, or ~1.5% of tile width) to prevent rendering seams at tile edges.
Implementation Patterns
Pattern 1: Static File Pipeline with Tippecanoe
The most common production pattern for datasets that change infrequently: generate all tiles once, store in PMTiles, serve from object storage.
# 1. Validate and reproject source data
ogr2ogr \
-f GeoJSON /tmp/roads_4326.geojson \
source/roads.geojson \
-t_srs EPSG:4326 \
-makevalid
# 2. Generate tiles: z0–z14, roads layer only, drop unused attributes
tippecanoe \
--output roads.pmtiles \
--layer roads \
--minimum-zoom 0 \
--maximum-zoom 14 \
--include name \
--include highway \
--include oneway \
--drop-densest-as-needed \
--extend-zooms-if-still-dropping \
/tmp/roads_4326.geojson
# 3. Inspect output before deploying
pmtiles show roads.pmtiles
pmtiles verify roads.pmtiles
The --drop-densest-as-needed flag lets Tippecanoe remove the densest features when a tile overflows 500 KB rather than aborting — critical for datasets with uneven spatial density. The essential Tippecanoe flags for production builds covers the full flag taxonomy.
Pattern 2: Python Subprocess Pipeline for Automated Nightly Builds
When the source dataset updates on a schedule, wrap Tippecanoe in a Python subprocess to integrate with data validation and alerting.
import subprocess
import sys
from pathlib import Path
def build_tiles(
input_geojson: Path,
output_pmtiles: Path,
layer: str,
min_zoom: int = 0,
max_zoom: int = 14,
) -> None:
cmd = [
"tippecanoe",
f"--output={output_pmtiles}",
f"--layer={layer}",
f"--minimum-zoom={min_zoom}",
f"--maximum-zoom={max_zoom}",
"--drop-densest-as-needed",
"--extend-zooms-if-still-dropping",
"--force", # overwrite existing output
str(input_geojson),
]
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
print(result.stderr, file=sys.stderr)
raise RuntimeError(f"tippecanoe failed with exit code {result.returncode}")
print(result.stderr) # tippecanoe progress goes to stderr
build_tiles(
Path("/data/roads_4326.geojson"),
Path("/output/roads_v2.pmtiles"),
layer="roads",
max_zoom=14,
)
For large GeoParquet inputs, converting large GeoParquet files to vector tiles covers streaming the conversion via pyarrow → NDJSON → Tippecanoe stdin to avoid materializing the full dataset as a .geojson file.
Pattern 3: On-the-Fly Generation from PostGIS with Martin
For datasets updated continuously — real-time tracking, live incident feeds — static pre-generation is not feasible. Martin serves MVT directly from PostGIS with sub-100 ms tile generation for typical urban extents:
# martin.toml
[postgres]
connection_string = "postgresql://user:pass@localhost/gisdb"
[[postgres.tables]]
schema = "public"
table = "incidents"
srid = 4326
geometry_column = "geom"
minzoom = 10
maxzoom = 18
martin --config martin.toml
# Tiles served at: http://localhost:3000/public.incidents/{z}/{x}/{y}
Martin does not apply zoom-level generalization by default — the database query returns all features within the tile bounding box. Add a PostGIS ST_Simplify call in a view for lower zooms, or use Martin’s built-in simplify_until_mvt_size option (Martin ≥ 0.13).
Performance and Scale Considerations
Tile Size Budget
The 500 KB uncompressed tile limit is a practical ceiling, not a hard spec constraint. In practice:
| Tile size (uncompressed) | Client effect |
|---|---|
| < 100 KB | Fast parse, no rendering stall |
| 100–500 KB | Acceptable; monitor parse time in DevTools |
| 500 KB–1 MB | Parse stalls main thread unless Web Workers are used |
| > 1 MB | MapLibre GL JS may drop the tile silently |
Gzip reduces wire size by roughly 70–80% for typical MVT payloads. Always serve with Content-Encoding: gzip or Content-Encoding: br (Brotli).
The budget is spent in a specific order, and knowing that order tells you which knob to reach for. Geometry vertices dominate at high zoom, attribute dictionaries dominate at low zoom on wide tables, and layer overhead is negligible until a tileset carries a dozen layers:
That distribution explains a result that surprises people the first time they measure it: on a wide attribute table, dropping columns can halve a tile without touching a single vertex, because every distinct string value is stored once per layer per tile and every key name is stored once per layer per tile. Simplification and attribute filtering are not alternatives — they attack different bands of the same budget.
Zoom-Level Data Inclusion Strategy
Zoom-level optimization controls which features appear at which zoom levels. A practical starting matrix:
| Zoom | Feature types included |
|---|---|
| z0–z5 | Country/continent boundaries, major ocean labels |
| z6–z9 | State/province boundaries, major roads, rivers |
| z10–z13 | City boundaries, arterial roads, parks, landcover |
| z14–z16 | Building footprints, address points, minor roads |
| z17–z18 | Individual units, detailed POIs |
Tippecanoe implements this via --minimum-zoom per layer or with feature-level zoom filters in GeoJSON properties.
Memory and CPU Constraints
Tippecanoe is single-threaded during the initial feature ingestion pass and memory-proportional to the input dataset. For datasets exceeding 10 GB GeoJSON:
- Use
--read-parallelto exploit multi-core machines during the initial read - Pipe from
geopandasorpyogriorather than materializing a full.geojsonfile - Monitor RSS; Tippecanoe typically needs 2–4× the uncompressed input size in RAM
Martin’s PostGIS backend is constrained by database connection pool size and query plan efficiency; add spatial indexes (CREATE INDEX ON table USING GIST(geom)) before production traffic.
Buffers, Clipping, and the Seam Problem
Quantisation raises a question the spec answers only implicitly: what happens to a road that crosses a tile edge? Every generator clips features to the tile boundary, because a tile is a self-contained message and a renderer never sees its neighbours while decoding it. Clipping alone produces visible seams — a 4-pixel-wide road casing gets cut exactly at the boundary, and the two halves are stroked independently, leaving a hairline gap where the joins do not meet.
The fix is a buffer: geometry is clipped not to the tile edge but to a slightly larger box, so each tile carries a strip of its neighbours’ geometry that the renderer draws and then masks. Tippecanoe’s --buffer defaults to 5 units of the 256-unit reference tile, which is 80 units on the default 4096 extent. Three consequences follow, and all three show up in production:
- Too small a buffer leaves seams on thick line casings, on halo’d labels, and on polygon outlines. Symptoms appear only at certain zoom levels, because stroke width in screen pixels is constant while tile extent in ground units halves each zoom.
- Too large a buffer inflates every tile. The buffer strip is duplicated into all four neighbours, so a buffer of 128 units on a dense tile can add 10–15% to the payload for geometry that is masked off and never seen.
- Label placement is not a buffer problem. A point label near a boundary appears in both tiles, and the renderer’s collision detection deduplicates it by feature id — which is why a missing or non-unique
idproduces double-drawn labels at tile boundaries and nowhere else.
When a seam appears, measure before changing the buffer. Decode the tile, check whether the geometry actually stops at the extent boundary or short of it, and confirm the stroke width at the zoom where the seam appears. Raising --buffer globally to fix one layer’s casing is a common and expensive over-correction.
Storage and Delivery
The container is not a storage detail — it determines the shape of the delivery tier, whether a process must be running to answer a tile request, and how a rebuild reaches readers. Make this choice before writing the build pipeline, because it decides what the pipeline’s last stage produces:
MBTiles: SQLite-Based Container
MBTiles packages tiles into a single SQLite database with a schema indexed by (zoom_level, tile_column, tile_row). Tiles are stored as BLOBs in the tiles table. The format is universally supported by desktop GIS tools and tile servers (TileServer GL, Martin, pg_tileserv).
-- MBTiles schema (read-only inspection)
SELECT zoom_level, COUNT(*) AS tile_count
FROM tiles
GROUP BY zoom_level
ORDER BY zoom_level;
MBTiles limitations for production CDN delivery:
- SQLite write locks prevent concurrent generation workers from writing the same file
- No native HTTP range request support — requires a tile server process
- File sizes above ~20 GB create operational friction (backup, transfer, atomic swap)
For resolving SQLite lock contention during large MBTiles builds, the standard fix is generating tiles into a directory of files first, then assembling the MBTiles in a single-writer pass.
PMTiles: Serverless, Range-Request Optimized Archives
PMTiles uses a single sequentially written archive where tile coordinates are mapped to byte offsets via a Hilbert curve–indexed directory. This layout allows CDNs and object storage (S3, Cloudflare R2) to serve individual tiles with a single HTTP range request — no tile server process required.
# Inspect PMTiles header and directory
pmtiles show output.pmtiles
# Verify all tiles are readable
pmtiles verify output.pmtiles
# Serve locally for development (proxies range requests)
pmtiles serve output.pmtiles --port 8080
The PMTiles specification deep dive covers the archive format, directory structure, and how to inspect metadata with CLI tools.
CDN Cache-Control Strategy
Serve tiles with versioned URL paths and immutable cache headers:
# Nginx example
location ~ ^/tiles/v(?<version>[0-9]+)/(?<z>[0-9]+)/(?<x>[0-9]+)/(?<y>[0-9]+)\.mvt$ {
add_header Cache-Control "public, max-age=31536000, immutable";
add_header Content-Encoding gzip;
add_header Content-Type "application/vnd.mapbox-vector-tile";
root /var/tiles;
}
When source data changes, rotate the version prefix (/v2/ → /v3/) rather than invalidating individual tiles. This guarantees instant global consistency without CDN purge API calls. The full header taxonomy, tile-server choices, and range-request hosting live in Tile Serving & CDN Delivery.
For PMTiles on Cloudflare R2 or S3, set the bucket’s default Cache-Control metadata on upload:
aws s3 cp roads_v2.pmtiles s3://my-tiles-bucket/roads_v2.pmtiles \
--cache-control "public, max-age=31536000, immutable" \
--content-type "application/octet-stream"
Failure Modes and Debugging
Tile Overflow: Payload Exceeds 500 KB
Symptom: Tippecanoe prints tile is too large warnings; MapLibre silently drops tiles.
Diagnosis:
# Find oversized tiles in a PMTiles archive
pmtiles show output.pmtiles | grep "max tile size"
# Find oversized tiles in an MBTiles container
sqlite3 output.mbtiles \
"SELECT zoom_level, tile_column, tile_row, length(tile_data) AS bytes
FROM tiles WHERE length(tile_data) > 500000
ORDER BY bytes DESC LIMIT 20;"
Fix: Add --drop-densest-as-needed to Tippecanoe, or reduce --maximum-zoom to a level where tiles stay under budget. For attribute-heavy datasets, dropping unused attributes reduces tile size substantially before generalization is needed.
Projection Mismatch: Geometry Renders off the Map
Symptom: Features appear at 0°N 0°E, or are displaced thousands of kilometres.
Diagnosis:
ogrinfo -al -so source.geojson | grep "SRS WKT"
# Look for PROJCRS or unexpected GEOGCRS authority
Fix: Always reproject to EPSG:4326 before passing to Tippecanoe:
ogr2ogr -t_srs EPSG:4326 -makevalid output_4326.geojson input.geojson
Y-Axis Inversion: TMS vs XYZ Flip
Symptom: Tiles appear with north and south swapped; the map renders upside-down.
Diagnosis: Check whether the tile server is serving TMS-indexed tiles to a client expecting XYZ convention (or vice versa).
Fix: Apply tms_y = (2^z - 1) - xyz_y in the URL template, or configure the tile server’s tms flag. In MapLibre GL JS, set "scheme": "tms" in the source definition if the origin is south-west.
MVT Parse Error: Unexpected End of Buffer
Symptom: Client throws Protobuf error: unexpected end of buffer or renders blank tiles.
Diagnosis: Tile was served with Content-Encoding: gzip but the client received it without decompression (or vice versa).
Fix: Inspect response headers with curl -I:
curl -I "https://tiles.example.com/v2/10/512/384.mvt"
# Confirm Content-Encoding matches Accept-Encoding
Ensure the tile server does not double-compress: if the .mvt file is already gzip-compressed on disk, do not apply gzip compression in the HTTP layer again.
Attribute Values Appear as Integers Instead of Strings
Symptom: A field that should be "residential" renders as 3.
Cause: The MVT values dictionary encodes typed values (string_value, int_value, float_value); a mismatch between the encoding type and the client’s expectation causes incorrect deserialization.
Fix: Inspect the raw protobuf:
# Decode a tile's protobuf with vtzero or similar
vtzero-show tile.mvt | head -80
Ensure source data has consistent types per attribute column — mixed int/string in GeoJSON properties will cause Tippecanoe to coerce all values to string.
Empty Tiles Where Data Exists
Symptom: The archive contains tiles for a region, the tile server returns 200, and the map draws nothing.
Cause: Almost always a name mismatch rather than missing data. The style’s source-layer must equal the layer name baked into the tile, which Tippecanoe takes from --layer, or from the input filename when --layer is omitted. A build that was switched from roads.geojson to roads_v2.geojson silently renames the layer from roads to roads_v2, and every style rule referencing roads stops matching.
Diagnosis:
# What layers does the archive actually contain?
pmtiles show roads_v2.pmtiles | grep -A20 "vector_layers"
# Or, straight from a single tile
tippecanoe-decode roads_v2.pmtiles 14 8192 5461 | head -20
Fix: Always pass --layer explicitly, and treat the layer name as a published interface. Contract-testing the source-layer names between the tileset and the style catches this in CI rather than in a browser.
The Tileset Builds but the Client Never Requests High Zooms
Symptom: Detail stops appearing above a certain zoom even though the archive was generated to z16.
Cause: minzoom/maxzoom in the TileJSON — or in the style’s source definition — cap what the client will ask for. A renderer does not probe: if the source declares maxzoom: 12, it overzooms z12 tiles rather than requesting z16, no matter what the archive holds.
Fix: Read the declared range out of the archive and compare it to the style:
pmtiles show roads_v2.pmtiles | grep -E "min zoom|max zoom"
jq '.sources[] | {url, minzoom, maxzoom}' style.json
The two must agree. When they disagree deliberately — serving z14 tiles and overzooming to z18 to save storage — say so in the style, because the next person to look at a blurry building outline will otherwise go hunting in the build pipeline. Overzooming versus generating a higher max zoom covers when each is the right trade.
FAQ
Do I need a tile server to serve vector tiles?
Only if the tiles come from a database or must be updated individually. A static tileset published as a PMTiles archive on object storage is read directly by the browser through HTTP range requests, with no process running anywhere on the read path.
What actually limits a tile to 500 KB?
Nothing in the specification — it is a practical ceiling. Above roughly 500 KB uncompressed, parsing stalls the browser’s main thread, and above about 1 MB MapLibre may drop the tile without reporting anything.
Why do my tiles render upside down?
The y-axis convention. XYZ counts y southward from the north edge, TMS counts it northward from the south. Apply tms_y = (2^z - 1) - xyz_y, or set the source’s scheme to tms.
Can I change a tileset’s styling without rebuilding it?
Yes, and it is the main reason to use vector tiles at all. Styling lives entirely in the style document, so a new palette, a dark theme or a different label font is a style deploy that touches no tile.
Topic Index
This section’s depth is organized across six focused topics:
MBTiles Architecture & Limits — SQLite schema internals, concurrent write constraints, file-size operational limits, and the lock contention patterns that break automated nightly builds.
PMTiles Specification Deep Dive — Archive header layout, Hilbert curve tile addressing, metadata inspection with CLI tools, and the PMTiles-versus-MBTiles decision for S3 and Cloudflare R2 delivery.
MVT Encoding Internals — The protobuf tile structure byte by byte: layers and features, the zigzag geometry command integers, the 4096 tile extent, and the key/value attribute tables that a style reads.
Tile Coordinate Systems & the Slippy Map Grid — EPSG:4326 versus EPSG:3857, the z/x/y grid, the XYZ-versus-TMS y-axis flip, quadkeys, and converting a latitude/longitude to a tile number.
Vector vs Raster Tile Tradeoffs — Where vector tile delivery outperforms raster (dynamic styling, offline caching, interactive querying) and where raster remains the right choice (satellite imagery, complex cartographic renders, legacy client compatibility).
Zoom Level Optimization Strategies — Feature inclusion rules by zoom, calculating the optimal maximum zoom for urban datasets, and choosing between overzoom and a higher generated max-zoom.
Related
- Automated Generation Pipelines with Tippecanoe — CLI flag taxonomy, attribute filtering rules, GeoParquet input processing, and geometry simplification algorithms for batch tile generation.
- Geometry Simplification Algorithms — How Tippecanoe chooses between Visvalingam–Whyatt and Douglas–Peucker at each zoom level, and when to override the default.
- Map Styling and Layer Synchronization — MapLibre GL JS style spec structure, dynamic attribute mapping, and binding data-driven paint properties to vector tile layer attributes.
- Attribute Filtering Rules —
--include,--exclude, and--exclude-allflag semantics for removing attributes that inflate tile size without adding client-side value. - GeoParquet Input Processing — Reading columnar GeoParquet files efficiently and streaming them to Tippecanoe without materializing intermediate GeoJSON.
- Tile Serving & CDN Delivery — How the MBTiles and PMTiles containers described here reach the browser: tile servers, object-storage range requests, cache-control headers, and versioned URLs.