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 for gdal2tiles.py raster generation
  • Tippecanoe ≥ 2.17 (tippecanoe --version) — vector tile generation; see essential Tippecanoe flags for flag semantics
  • Python ≥ 3.10 with requests, rasterio, and psutil installed
  • Source data: a GeoJSON or .geoparquet file for vector; a GeoTIFF (EPSG:4326) for raster
  • A staging tile server serving both .pbf (e.g., martin) and .png endpoints
  • 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.

Vector vs raster tile delivery pipeline Two parallel pipelines from source data to browser render. The vector path encodes geometry into Protocol Buffers, stores in MBTiles or PMTiles, and renders client-side via WebGL. The raster path rasterises server-side into PNG/WebP tiles and renders as static images. Source Encode Store Render GeoJSON / GeoTIFF EPSG:4326 input tippecanoe Protocol Buffers (.pbf) 10–60 KB/tile .mbtiles / .pmtiles CDN range-request delivery WebGL / MapLibre GL dynamic styling, client-side gdal2tiles.py PNG / WebP 40–150 KB/tile Directory / S3 / .mbtiles immutable; long max-age safe Canvas / <img> draw fixed style, server-baked VECTOR PATH RASTER PATH

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

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

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

js
// 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:

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

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

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

bash
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


Next reading When to Use Vector Tiles Over Raster for Web Maps