Vector vs Raster Tile Tradeoffs
Choosing between vector .pbf and raster .png/.webp tiles is a systems engineering decision, not a visual preference. The choice cascades through every downstream stage: tile generation compute, MBTiles SQLite container sizing, CDN cache TTL strategy, client-side GPU budget, and style update cadence. This page dissects each dimension with measurable parameters and ends with a reproducible evaluation workflow.
Prerequisites
Before running the evaluation workflow, confirm:
- GDAL/OGR ≥ 3.6 (
gdal-config --version) — needed forgdal2tiles.pyraster generation - Tippecanoe ≥ 2.17 (
tippecanoe --version) — vector tile generation; see essential Tippecanoe flags for flag semantics - Python ≥ 3.10 with
requests,rasterio, andpsutilinstalled - Source data: a GeoJSON or
.geoparquetfile for vector; a GeoTIFF (EPSG:4326) for raster - A staging tile server serving both
.pbf(e.g., martin) and.pngendpoints k6≥ 0.50 for load simulation
Format Comparison at a Glance
The SVG below maps the two delivery paths from source data to browser render. Annotated points show where each format introduces irreversible decisions.
Core Tradeoff Dimensions
Bandwidth and Transfer Size
| Metric | Vector (.pbf) | Raster PNG | Raster WebP |
|---|---|---|---|
| Typical z14 urban tile | 15–55 KB | 60–140 KB | 30–80 KB |
| Tile size at z10 (sparse) | 2–8 KB | 10–40 KB | 5–20 KB |
| Scales with attribute density | Yes | No (pixel-bound) | No |
| Compressible on CDN | Yes (gzip ~30% gain) | Minimal (already binary) | Minimal |
| Max safe tile budget | 500 KB | 500 KB | 500 KB |
Vector tiles compress geometry via Protocol Buffers, then further via gzip/brotli at the HTTP layer. Raster tiles are already pixel-compressed — PNG uses DEFLATE, WebP uses lossy/lossless discrete cosine transforms. The vector advantage narrows for dense attribute schemas (many string properties per feature) and widens for sparse datasets like road networks.
Styling Flexibility
Raster tiles bake cartographic decisions at generation time. Changing a road colour, adjusting label hierarchy, or switching to a dark theme requires regenerating the entire tileset or maintaining parallel raster caches per theme. Vector tiles decouple data from presentation: the MapLibre GL JSON style controls colour, font, icon, and visibility at runtime. This makes data-driven property binding — e.g. colouring features by a numeric attribute — trivial in vector but impossible in raster without server-side re-render.
Server Compute and Caching
Raster generation is CPU-intensive during pre-processing (resampling, reprojection, tile cutting) but zero-cost at request time — the server reads a file and streams bytes. Vector generation via Tippecanoe’s simplification and attribute filtering is lighter per tile but produces outputs that change when the source data or style schema changes.
Cache TTL implications differ sharply:
| Dimension | Vector tiles | Raster tiles |
|---|---|---|
Cache-Control |
Shorter TTL or versioned URLs when style/data changes | max-age=31536000, immutable safe for static datasets |
| Cache busting strategy | Increment tile URL version prefix on data rebuild | Content-hash suffix on source GeoTIFF → full retile |
| CDN edge cache fill | Slower (many small .pbf files) |
Faster (fewer, larger PNG files) |
| Partial update | Retile only changed z/x/y tiles | Usually full retile (raster continuity matters) |
Client-Side Rendering Overhead
Raster rendering is a DOM/canvas image draw — predictable across devices but aliased at non-native zoom levels. Vector rendering requires a WebGL context, geometry parsing, style evaluation, and on-GPU rasterisation per frame. On low-end Android devices (≤ 2 GB RAM, Adreno 300-series GPU), complex polygon layers with fill-extrusion or dense symbol layers can drop to sub-20 fps. Profile GPU draw calls using Chrome DevTools > Performance > Layers before committing vector tiles to broad consumer audiences.
Step-by-Step Evaluation Workflow
Step 1 — Collect Baseline Transfer Metrics
# benchmark_tiles.py — measure transfer size and latency per format per zoom
import time
import csv
import requests
VECTOR_URL = "http://localhost:7800/tiles/{layer}/{z}/{x}/{y}"
RASTER_URL = "http://localhost:8080/tiles/{z}/{x}/{y}.png"
# Representative sample: z10 sparse, z14 urban, z16 dense
SAMPLES = [
{"z": 10, "x": 542, "y": 359},
{"z": 14, "x": 8682, "y": 5741},
{"z": 16, "x": 34732, "y": 22966},
]
results = []
for s in SAMPLES:
for fmt, url_tpl in [("vector", VECTOR_URL), ("raster", RASTER_URL)]:
url = url_tpl.format(layer="roads", **s)
t0 = time.perf_counter()
r = requests.get(url, timeout=10)
elapsed_ms = round((time.perf_counter() - t0) * 1000, 1)
results.append({
"format": fmt,
"zoom": s["z"],
"size_kb": round(len(r.content) / 1024, 2),
"latency_ms": elapsed_ms,
"status": r.status_code,
})
with open("tile_benchmark.csv", "w", newline="") as f:
writer = csv.DictWriter(f, fieldnames=results[0].keys())
writer.writeheader()
writer.writerows(results)
print("Benchmark saved to tile_benchmark.csv")
Verify: confirm size_kb for z14 vector tiles stays under 500 KB. Tiles above this threshold cause client decode stalls and should trigger attribute dropping.
Step 2 — Run Automated Generation Pipelines
Both commands must target identical geographic extents and zoom ranges for a fair comparison.
# Raster generation — GeoTIFF → PNG tile pyramid, zoom 10–16
gdal2tiles.py \
--processes=4 \
--zoom=10-16 \
--resampling=lanczos \
--profile=mercator \
--tilesize=256 \
--srcnodata=0 \
source_EPSG4326.tif \
./output/raster/
# Vector generation — GeoJSON (EPSG:4326) → .mbtiles, zoom 10–16
tippecanoe \
--output=./output/vector/roads.mbtiles \
--layer=roads \
--minimum-zoom=10 \
--maximum-zoom=16 \
--drop-densest-as-needed \
--extend-zooms-if-still-dropping \
--coalesce-densest-as-needed \
--simplification=10 \
source.geojson
Verify: the .mbtiles file opens cleanly with sqlite3 roads.mbtiles "SELECT count(*) FROM tiles;". If applying geometry simplification, run tippecanoe-decode on a representative tile to confirm vertex counts are reasonable.
Step 3 — Simulate Concurrent Cache Load
// k6_tile_load.js — run with: k6 run k6_tile_load.js
import http from "k6/http";
import { sleep, check } from "k6";
export const options = {
vus: 50,
duration: "30s",
};
const TILES = [
"http://localhost:7800/tiles/roads/14/8682/5741", // vector
"http://localhost:8080/tiles/14/8682/5741.png", // raster
];
export default function () {
TILES.forEach((url) => {
const res = http.get(url);
check(res, {
"status 200": (r) => r.status === 200,
"under 500KB": (r) => r.body.length < 512000,
});
});
sleep(0.1);
}
Inspect the k6 summary for http_req_duration p95 — raster should show lower server-side duration (file read) but higher transfer time; vector should show the inverse. CDN cache-hit ratios for vector endpoints drop sharply when style versioning causes URL churn; verify Cache-Control response headers match your intended TTL strategy.
Step 4 — Apply the Decision Matrix
| Use case | Recommended format | Rationale |
|---|---|---|
| Satellite / aerial imagery basemap | Raster (WebP) | Pixel fidelity paramount; no styling needed |
| Interactive POI filtering, dynamic theming | Vector | Runtime attribute access and style expression binding |
| Low-bandwidth / emerging markets | Raster (WebP/AVIF) | Predictable decode; no WebGL dependency |
| High-density urban routing, transit | Vector | Topology preservation; crisp lines at all zoom levels |
| Offline mobile app | Vector + PMTiles | Single-file; HTTP range-request delivery; no tile server |
| Print cartography | Raster (high-DPI PNG) | Pixel-accurate output; no WebGL required |
| Multi-language label rendering | Vector | Labels stored as attributes, styled at runtime per locale |
Optimization Knobs
1 — Tile Size vs Attribute Richness (Vector)
--simplification value |
Geometry quality | Avg tile size change |
|---|---|---|
| 4 (default) | Near-lossless for z14+ | Baseline |
| 10 | Visible simplification at z16 | −20 to −35% |
| 20 | Acceptable for z10–12 overview | −40 to −55% |
Combine with attribute filtering rules — dropping even two unused string properties can cut tile size by 15–25% on feature-dense layers.
2 — Raster Compression vs Visual Quality
| Format | Typical z14 size | Decode speed | Lossy artefacts |
|---|---|---|---|
| PNG-8 | 25–50 KB | Fast | None (palette quantisation) |
| PNG-32 | 60–140 KB | Fast | None |
| WebP lossy (q=80) | 15–40 KB | Moderate | Subtle at label edges |
| WebP lossless | 30–80 KB | Slower | None |
Pass --webp to gdal2tiles.py (GDAL ≥ 3.1) to produce .webp tiles; serve with Content-Type: image/webp.
3 — Zoom Level Strategy
For either format, every additional zoom level roughly quadruples the tile count. Limiting maximum zoom to the dataset’s natural resolution avoids generating tiles where vector geometry is over-simplified or raster pixels are upscaled. As a rule of thumb: 1 m/px imagery supports z18 at most; 10 m/px imagery degrades past z16.
Integration with Adjacent Pipeline Stages
Vector output → tile server → CDN. After generating .mbtiles, use martin or tileserver-gl to serve tiles with appropriate Content-Encoding: gzip headers. For static hosting, convert to PMTiles format with pmtiles convert roads.mbtiles roads.pmtiles and upload to Cloudflare R2 or S3 — the PMTiles HTTP range-request specification allows the browser to fetch individual tiles without a server process.
Raster output → S3/R2 directory → CDN. Raster tile directories map directly to static object storage. Set Cache-Control: public, max-age=31536000, immutable on .png/.webp objects. When you regenerate tiles, rotate the URL version prefix (/tiles/v3/…) rather than purging the CDN edge cache.
Style coupling. Vector tiles require a MapLibre GL style JSON that references the correct tile source URL and layer names. Version the style JSON independently of the tile archive — a style update does not require a tile retile, but a layer rename does.
Troubleshooting
1 — Vector tiles exceed 500 KB at high zoom
Symptom: tippecanoe logs tile is too large; client shows blank tiles at z16+.
Diagnosis:
tippecanoe-decode roads.mbtiles 16 34732 22966 | python3 -c "
import sys, json
data = json.load(sys.stdin)
for layer in data['features']:
print(layer['geometry']['type'], len(json.dumps(layer)))
"
Fix: Add --drop-densest-as-needed and reduce --simplification below 4 (counter-intuitively, a higher number simplifies more aggressively). Also drop unused attributes before generation.
2 — Raster tiles show seam artefacts at tile boundaries
Symptom: Thin white or dark lines at 256-pixel intervals on the map.
Diagnosis: Compare adjacent tiles — consistent one-pixel borders indicate padding (--expand in gdal2tiles.py or NODATA bleed-through).
Fix:
gdal2tiles.py --zoom=10-16 --resampling=lanczos --srcnodata="0 0 0" source.tif ./output/raster/
Set --srcnodata to the exact background pixel value to prevent transparent halo bleed.
3 — CDN serves stale vector tiles after data update
Symptom: Clients see outdated geometry after a rebuild; hard refresh fetches updated tiles.
Fix: Use versioned URL paths and increment on rebuild:
# In your build script
VERSION=$(date +%Y%m%d%H%M)
tippecanoe -o roads_${VERSION}.mbtiles ...
# Upload to /tiles/v${VERSION}/{z}/{x}/{y}.pbf
# Update style JSON source URL to reference new version
Never rely on Cache-Control: no-cache for tile endpoints under CDN load — origin fetch costs compound rapidly at scale.
4 — WebGL context lost on mobile
Symptom: MapLibre map goes blank; console shows WebGL context lost.
Diagnosis: Device exhausted GPU memory. Check layer count and zoom range in the style JSON.
Fix: Reduce concurrent rendered layers, increase maxTileCacheSize in MapLibre options, or fall back to a raster basemap at zoom levels below z12 where vector geometry is sparse.
5 — Projection mismatch (EPSG:4326 vs EPSG:3857)
Symptom: Tiles render in the wrong location; coordinates are off by hundreds of kilometres.
Diagnosis:
gdalinfo source.tif | grep "AUTHORITY"
# Should return AUTHORITY["EPSG","4326"] for input; tiles are reprojected to 3857 during generation
Fix: If your source GeoTIFF is already in EPSG:3857, pass --profile=mercator explicitly to gdal2tiles.py. For GeoJSON, Tippecanoe expects EPSG:4326 input and handles the Web Mercator reprojection internally.
Further Reading
- When to Use Vector Tiles Over Raster for Web Maps — specific conditions (device class, data lifecycle, styling cadence) that tilt the decision toward vector, with a checklist for teams evaluating the switch.
Related
- Vector Tile Architecture & Format Fundamentals — parent section covering the full GeoJSON → MBTiles/PMTiles → CDN pipeline and the MVT specification.
- MBTiles Architecture & Limits — SQLite storage constraints, WAL mode, and filesystem inode limits that affect both vector and raster tile archives at scale.
- PMTiles Specification Deep Dive — how range-request indexing enables serverless delivery of vector and raster tile archives from static object storage.
- Tippecanoe CLI Fundamentals — flag taxonomy and production build patterns for vector tile generation.
- Zoom Level Optimization Strategies — calculating the right maximum zoom to avoid over-tiling raster datasets or over-simplifying vector geometry.