Part of the Tile vs Vector Rendering Strategies guide.
Operative rule: Default to vector tiles for any dashboard that requires runtime styling, feature-level querying, or data updates faster than your CDN cache TTL — use raster tiles only when serving pre-rendered imagery, targeting WebGL-constrained clients, or distributing locked cartographic outputs.
How It Works
The tile format you select dictates your entire rendering architecture. Raster tiles are server-generated PNG or JPEG images baked at fixed zoom levels (typically 0–22). They shift the computational load to the backend, guaranteeing pixel-perfect visual parity across browsers and devices. The trade-off is rigidity: styling is locked at generation time, and any data update requires full cache invalidation and tile reprocessing — a pattern described in detail under Cache Invalidation Strategies.
Vector tiles deliver raw geometry encoded in Protocol Buffers, following the open Mapbox Vector Tile specification. The client browser handles rendering via WebGL, allowing you to apply JSON-driven style sheets, toggle layers, and query individual features on the fly. This client-heavy model aligns directly with Core Mapping Architecture & Rendering patterns that favor decoupled data pipelines, reactive UIs, and reduced server-side compute costs. The specific renderer you pair with vector tiles — Leaflet with a plugin versus MapLibre GL JS natively — is the central question covered in the parent Tile vs Vector Rendering Strategies guide.
The diagram below illustrates where each format places its rendering work, and how data flows from source to the user’s screen.
Decision Matrix
| Criterion | Raster Tiles | Vector Tiles |
|---|---|---|
| Rendering location | Server-side (pre-baked) | Client-side (WebGL/GPU) |
| Styling flexibility | Fixed at generation | Runtime, JSON-driven |
| Data freshness | Requires cache busting & regeneration | Instant via updated tile endpoints |
| Bandwidth profile | Higher payload, highly cacheable | Lower payload, heavier CPU/GPU load |
| Interactivity | Pixel-level click/hover only | Full feature-level querying & filtering |
| Leaflet compatibility | Native (L.tileLayer) |
Requires plugin (leaflet.vectorgrid) |
| MapLibre GL JS | Via raster source type | Native, full style-spec support |
| Best for | Orthophotos, hillshades, static basemaps | Live sensor feeds, thematic switching, analytics |
Production-Ready Implementation
The following snippet loads vector tiles with MapLibre GL JS, indexes features with flatbush for instant client-side filtering, and applies a dashboard filter (by sensor threshold) without a network round-trip. This is the most common real-world pattern for live analytics dashboards.
import maplibregl from 'maplibre-gl';
import flatbush from 'flatbush';
import pbf from 'pbf';
import { VectorTile } from '@mapbox/vector-tile';
// --- 1. Initialise the map with a vector tile source ---
const map = new maplibregl.Map({
container: 'map',
style: {
version: 8,
sources: {
sensors: {
type: 'vector',
tiles: ['https://tiles.example.com/sensors/{z}/{x}/{y}.mvt'],
minzoom: 0,
maxzoom: 14
}
},
layers: [
{
id: 'sensors-circle',
type: 'circle',
source: 'sensors',
'source-layer': 'sensors',
paint: {
// Runtime expression: colour by value threshold
'circle-color': [
'case',
['>=', ['get', 'value'], 80], '#e63946',
['>=', ['get', 'value'], 40], '#f4a261',
'#2a9d8f'
],
'circle-radius': 6
}
}
]
}
});
// --- 2. Load a tile manually for spatial indexing ---
export async function loadAndIndexTile(url: string) {
const response = await fetch(url);
if (!response.ok) throw new Error(`Tile fetch failed: ${response.status}`);
const buffer = await response.arrayBuffer();
const tile = new VectorTile(new pbf(buffer));
const layer = tile.layers['sensors'];
if (!layer) return { features: [], index: null };
const features: Array<{ id: number; properties: Record<string, unknown>; bbox: [number,number,number,number] }> = [];
for (let i = 0; i < layer.length; i++) {
const feature = layer.feature(i);
const geom = feature.loadGeometry();
features.push({
id: feature.id ?? i,
properties: feature.properties,
bbox: getBBox(geom)
});
}
// Build spatial index for O(log n) bounding-box queries
const index = new flatbush(features.length);
for (const f of features) {
index.add(f.bbox[0], f.bbox[1], f.bbox[2], f.bbox[3]);
}
index.finish();
return { features, index };
}
// --- 3. Apply a dashboard filter via MapLibre expression (no network round-trip) ---
export function applyThresholdFilter(map: maplibregl.Map, minValue: number) {
map.setFilter('sensors-circle', ['>=', ['get', 'value'], minValue]);
}
function getBBox(geom: Array<Array<[number, number]>>): [number, number, number, number] {
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const ring of geom) {
for (const [x, y] of ring) {
if (x < minX) minX = x;
if (y < minY) minY = y;
if (x > maxX) maxX = x;
if (y > maxY) maxY = y;
}
}
return [minX, minY, maxX, maxY];
}
Alternative Variants
Raster tiles with Leaflet (static basemap)
Use this when serving orthophotos or locked cartographic outputs where no runtime styling is needed. Leaflet’s L.tileLayer has zero WebGL dependency.
import L from 'leaflet';
const map = L.map('map').setView([51.505, -0.09], 13);
// XYZ raster tile source — swap URL for your CDN endpoint
L.tileLayer('https://tiles.example.com/basemap/{z}/{x}/{y}.png', {
attribution: '© Example',
maxZoom: 19,
// Long cache TTL is safe because raster tiles are immutable at a given URL
// Use cache-busting query params (?v=2) when regenerating after a data update
}).addTo(map);
For teams managing the tile generation lifecycle, clearing browser tile cache after Python data updates covers the full cache-busting workflow for raster endpoints.
Hybrid: raster basemap + vector overlay
The most common production pattern pairs a stable raster basemap (low update cadence, zero WebGL cost) with a vector overlay layer (high update cadence, full interactivity):
// MapLibre GL JS hybrid source configuration
const style = {
version: 8,
sources: {
// Raster base — served from CDN, long-lived cache
basemap: {
type: 'raster',
tiles: ['https://tiles.example.com/base/{z}/{x}/{y}.png'],
tileSize: 256
},
// Vector overlay — served from dynamic tile endpoint
incidents: {
type: 'vector',
tiles: ['https://api.example.com/incidents/{z}/{x}/{y}.mvt']
}
},
layers: [
{ id: 'basemap', type: 'raster', source: 'basemap' },
{
id: 'incidents-fill',
type: 'fill',
source: 'incidents',
'source-layer': 'incidents',
paint: { 'fill-color': '#e63946', 'fill-opacity': 0.55 }
}
]
};
Verification Steps
Before deploying to production, confirm the following:
- Network tab: vector tile requests return
Content-Type: application/x-protobuf(orapplication/vnd.mapbox-vector-tile) and compress to under 50 KB per tile at zoom 12. - MapLibre devtools: open the browser console and run
map.getStyle().sources— confirm your source type is'vector', not'raster', for dynamic layers. - Filter round-trip: call
applyThresholdFilter(map, 60)and confirm the layer updates without a network request in the Network tab. Zero new tile requests means filtering is fully client-side. - WebGL fallback: simulate a WebGL failure with
chrome://flags/#disable-webgl. Raster layers should still render; vector layers will be blank — add a feature-detect and show a warning banner if!map.isSourceLoaded('sensors')after 5 seconds. - Cache headers: inspect raster tile responses —
Cache-Control: public, max-age=86400is the minimum for effective CDN caching. Vector tiles serving live data should useCache-Control: no-cacheor a shortmax-agematching your data update cadence. This interacts directly with the Scheduled Map Rebuild Workflows that regenerate tiles on a timer.
Common Errors & Fixes
source-layer is empty or layer never renders
Symptom: vector layer is added but nothing appears on the map; map.querySourceFeatures('sensors') returns [].
Root cause: the 'source-layer' name in the style spec does not match the layer name encoded inside the .mvt file. Layer names are set at tile generation time (e.g. tippecanoe --layer=sensors).
Fix: inspect the tile with vt-pbf or the Mapbox Vector Tile inspector to confirm the exact layer name, then match it in your style spec’s 'source-layer' property.
Raster tiles load but show stale data after a rebuild
Symptom: updated GeoJSON was processed and new PNG tiles were uploaded to S3, but users still see the old map.
Root cause: CDN edge nodes are serving the previous tile version from cache. Cache-Control headers or the CDN TTL have not been invalidated.
Fix: append a cache-busting query parameter to the tile URL (?v={timestamp}) or issue a CDN purge via the provider’s API after each rebuild. The full workflow is documented in Cache Invalidation Strategies.
WebGL: INVALID_OPERATION: drawElements: no buffer is bound
Symptom: MapLibre throws this WebGL error for high-density vector layers when the user pans rapidly.
Root cause: a large number of features are being re-tessellated simultaneously, exhausting the WebGL buffer pool.
Fix: reduce feature density at lower zoom levels using tippecanoe’s --drop-densest-as-needed flag during tile generation, or add 'minzoom' thresholds to detail layers so they only activate above zoom 10.
EPSG axis-order mismatch: features appear in the ocean
Symptom: feature centroids are rendered ~90 degrees off — points in Europe appear in the South Atlantic.
Root cause: the geometry was stored in EPSG:4326 with (latitude, longitude) axis order but consumed as (longitude, latitude). This is the most common CRS-related bug in tile pipelines.
Fix: confirm axis order before tile generation. The definitive reference for this project is CRS & Projection Management, which details how to audit and correct axis order across geopandas, shapely, and GeoJSON sources.
Related
- Tile vs Vector Rendering Strategies — parent guide covering renderer selection, style-spec architecture, and performance budgets
- Best Base Map Providers for High-Contrast Geo-Dashboards — raster basemap provider comparison with contrast and licensing notes
- Clearing Browser Tile Cache After Python Data Updates — cache-busting patterns for raster tile pipelines after backend regeneration
- Cache Invalidation Strategies — CDN purge, versioned URL, and ETag patterns for tile endpoints