Tile vs Vector Rendering Strategies
Part of the Core Mapping Architecture & Rendering guide.
In automated web mapping, the choice between raster and vector tile delivery dictates performance ceilings, interactivity models, and long-term maintenance overhead. This page gives frontend developers, GIS analysts, and agency teams the architectural context and step-by-step implementation patterns needed to deploy production-grade rendering pipelines — and to switch strategies as data volatility and audience hardware profiles change.
Prerequisites
Before implementing either rendering strategy, verify the following:
- WebGL 1.0+ support confirmed for your target browsers. Vector rendering relies on GPU-accelerated canvas contexts; document the fallback path for environments where
canvas.getContext('webgl')returnsnull - CRS alignment verified. Mismatched coordinate reference systems cause tile grid misalignment, clipping, or total rendering failure. Complete CRS & Projection Management
- Client library selected.
maplibre-gl(≥ 4.x) for vector-first workflows;leaflet(≥ 1.9) for lightweight raster consumption;openlayers - Bundler configured for tree-shaking.
- Tile server outputting consistent XYZ or TMS grids. Confirm
{z}/{x}/{y}
How the Two Pipelines Differ
The diagram below illustrates where each pipeline places the rendering workload — on the server for raster, on the client GPU for vector.
Key architectural difference: raster tiles commit styles on the server and ship pixels; vector tiles ship raw geometry and apply styles in the browser via WebGL — enabling runtime mutations without a server round-trip.
| Dimension | Raster Tiles | Vector Tiles |
|---|---|---|
| Format | Pre-baked images (PNG, WebP, JPEG) | Structured geometry (.pbf via Protocol Buffers) |
| Rendering location | Server-side | Client-side (WebGL / Canvas) |
| Styling flexibility | Fixed at generation time | Dynamic, runtime expressions |
| Bandwidth per zoom | Higher — full image re-downloaded | Lower — geometry deltas only |
| Interactivity | Bounding-box hit-testing only | Feature-level querying, hover, filtering |
| GPU demand | Near-zero | Moderate to high (depends on feature count) |
| Best fit | Satellite imagery, static thematic overlays, archival maps | Real-time dashboards, choropleths, multi-layer analytics |
The rendering choice also shapes Base Layer Selection & Switching logic: raster base layers and vector overlays can coexist in a single maplibre-gl style by stacking sources, but mixing two raster providers with different tile grids requires explicit layer ordering to prevent z-index conflicts.
Step 1 — Implement the Raster Tile Pipeline
Raster pipelines prioritise predictable visual output and near-zero client-side compute. The canonical pattern slices source imagery into an immutable XYZ tile grid and serves it via CDN.
# Generate a WebP XYZ tile cache from a GeoTIFF with gdal2tiles
gdal2tiles.py \
--profile mercator \
--zoom 0-16 \
--tilesize 256 \
--webviewer none \
input.tif \
output_tiles/
Wire the tile directory into Leaflet:
import L from 'leaflet';
const map = L.map('map').setView([51.5, -0.09], 12);
L.tileLayer('/tiles/{z}/{x}/{y}.webp', {
attribution: '© My Org',
tileSize: 256,
maxZoom: 16,
// Immutable tiles: set a long TTL at the CDN level
crossOrigin: true,
}).addTo(map);
// Graceful 404 fallback — show a transparent placeholder rather than a broken image
L.tileLayer('/tiles/fallback/{z}/{x}/{y}.png', {
opacity: 0,
maxNativeZoom: 12,
}).addTo(map);
Cache-control for raster tiles. Once generated, raster tiles are byte-for-byte immutable. Configure your CDN (or S3 static hosting) to emit Cache-Control: public, max-age=31536000, immutable. Bust the cache by publishing tiles to a new path prefix (e.g. /tiles/v2/) rather than invalidating individual keys — this avoids thundering-herd behaviour when a new dataset lands. This strategy interacts directly with Cache Invalidation Strategies for automated pipelines that regenerate tiles on data updates.
Error handling. Wrap tile layer construction in a try/catch. Listen for tileerror events and implement exponential back-off before retrying failed requests:
layer.on('tileerror', (err) => {
console.warn('Tile failed:', err.tile.src);
// Optionally swap to a lower-resolution fallback source
});
Step 2 — Implement the Vector Tile Pipeline
Vector pipelines shift rendering to the client GPU, unlocking dynamic styling, precise hit-testing, and bandwidth efficiency at the cost of CPU cycles for Protocol Buffer decoding.
2a. Generate .pbf tiles with tippecanoe
tippecanoe \
--output-to-directory=vector_tiles/ \
--force \
--minimum-zoom=0 \
--maximum-zoom=14 \
--drop-densest-as-needed \ # simplify geometry at low zoom levels
--extend-zooms-if-still-dropping \
--layer=districts \
districts.geojson
Keep source attributes lean: strip columns that will never be used in style expressions or tooltips. Every extra attribute increases .pbf payload size and Protocol Buffer parse time.
2b. Author a MapLibre GL style
import maplibregl from 'maplibre-gl';
const map = new maplibregl.Map({
container: 'map',
style: {
version: 8,
sources: {
districts: {
type: 'vector',
tiles: ['/vector_tiles/{z}/{x}/{y}.pbf'],
minzoom: 0,
maxzoom: 14,
},
},
layers: [
{
id: 'districts-fill',
type: 'fill',
source: 'districts',
'source-layer': 'districts', // must match the --layer name in tippecanoe
paint: {
// Runtime expression: colour by population density
'fill-color': [
'interpolate', ['linear'],
['get', 'pop_density'],
0, '#f7fbff',
500, '#2171b5',
5000,'#08306b',
],
'fill-opacity': 0.75,
},
},
{
id: 'districts-stroke',
type: 'line',
source: 'districts',
'source-layer': 'districts',
paint: { 'line-color': 'currentColor', 'line-width': 0.5, 'line-opacity': 0.4 },
},
],
},
center: [0, 51.5],
zoom: 9,
});
// Handle WebGL context loss — common on low-memory mobile devices
map.getCanvas().addEventListener('webglcontextlost', (e) => {
e.preventDefault();
document.getElementById('map-error').textContent =
'Map context lost — tap to reload.';
});
map.on('error', (err) => {
console.error('MapLibre GL error:', err.error);
});
2c. Validate the style JSON before deployment
Run @mapbox/mapbox-gl-style-spec linting in CI to catch expression type errors before they reach production:
npx gl-style-validate style.json
Use TypeScript interfaces for style layers so build-time type-checking catches source-layer name mismatches:
import type { LayerSpecification } from 'maplibre-gl';
const fillLayer: LayerSpecification = {
id: 'districts-fill',
type: 'fill',
source: 'districts',
'source-layer': 'districts',
paint: { 'fill-color': '#2171b5', 'fill-opacity': 0.75 },
};
Step 3 — Optimise Bandwidth and GPU Utilisation
Network efficiency and GPU utilisation are the primary bottlenecks in spatial dashboards, regardless of which pipeline you choose.
Vector tile size. Keep individual .pbf tiles under 500 KB (uncompressed). Beyond that, Protocol Buffer parse time causes visible jank during pan operations. Use tippecanoe’s --drop-densest-as-needed to reduce feature density at lower zoom levels automatically. Inspect tile sizes with:
find vector_tiles/ -name '*.pbf' -size +500k -exec ls -lh {} \;
Viewport-only requests. Only request tiles that intersect the current viewport. Debounce moveend listeners to prevent request storms during rapid panning:
map.on('moveend', debounce(() => {
const bounds = map.getBounds();
fetchAdditionalData(bounds); // only called once panning settles
}, 150));
Compression. Serve raster tiles as WebP with AVIF fallback (saves 20–40% over PNG). Vector .pbf files should be served with Content-Encoding: br (Brotli) at the CDN level — MapLibre GL decompresses transparently. Check that your CDN is actually compressing:
curl -sI -H 'Accept-Encoding: br' https://example.com/tiles/8/130/86.pbf \
| grep content-encoding
# Expected: content-encoding: br
GPU memory. Monitor program cache size when switching between heavy vector datasets. Calling map.remove() before mounting a new map instance is the safest way to release all WebGL resources and avoid context exhaustion.
Step 4 — Add Feature-Level Interactivity
Vector rendering enables hit-testing at the geometry level. Attaching tooltips and highlight states costs no server round-trips:
// Highlight on hover
map.on('mousemove', 'districts-fill', (e) => {
if (!e.features?.length) return;
map.getCanvas().style.cursor = 'pointer';
const feat = e.features[0];
map.setPaintProperty('districts-fill', 'fill-opacity', [
'case',
['==', ['get', 'id'], feat.properties.id],
1.0, // hovered feature
0.6, // all others
]);
});
map.on('mouseleave', 'districts-fill', () => {
map.getCanvas().style.cursor = '';
map.setPaintProperty('districts-fill', 'fill-opacity', 0.75);
});
// Tooltip on click
map.on('click', 'districts-fill', (e) => {
const props = e.features[0].properties;
new maplibregl.Popup()
.setLngLat(e.lngLat)
.setHTML(`<strong>${props.name}</strong><br>Density: ${props.pop_density}/km²`)
.addTo(map);
});
Raster tiles do not expose feature attributes at the client. For bounding-box–level interactions on a raster layer, you must query a separate spatial index — typically a server-side endpoint using PostGIS or a pre-built spatial index file — and then correlate the click coordinates to features manually.
Runtime style swapping. The most powerful vector workflow for dashboard builders is swapping data attributes into a fill-color expression without refetching geometry. This eliminates server round-trips and reduces perceived latency to near-zero:
function applyTheme(attribute: string): void {
map.setPaintProperty('districts-fill', 'fill-color', [
'interpolate', ['linear'], ['get', attribute],
0, '#f7fbff', 5000, '#08306b',
]);
}
// Toggle between metrics instantly
applyTheme('median_income');
applyTheme('unemployment_rate');
This pattern pairs naturally with Layer Management & Toggling when dashboards expose per-metric visibility controls.
Step 5 — Production Deployment and Monitoring
Telemetry
Track the following metrics via PerformanceObserver and custom map event hooks:
tileLoadTime— time from tile request initiation to first-pixel paint. Alert if p95 exceeds 500 ms.renderFPS— sustained frame rate during pan and zoom. Alert if FPS drops below 30.- WebGL context loss events per session — a rate above 0.5% indicates GPU memory pressure.
// Measure tile load time
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.name.includes('.pbf') || entry.name.includes('.webp')) {
analytics.track('tile_load', { duration: entry.duration, url: entry.name });
}
}
});
observer.observe({ type: 'resource', buffered: true });
Raster fallback for WebGL failures
Implement a raster fallback layer that activates when getContext('webgl') returns null or the webglcontextlost event fires:
function initMap(): void {
const canvas = document.createElement('canvas');
const hasWebGL = !!(canvas.getContext('webgl') || canvas.getContext('experimental-webgl'));
if (hasWebGL) {
initVectorMap();
} else {
initRasterFallback(); // Leaflet + raster XYZ tiles
}
}
CI/CD integration
Automate style validation in pull requests:
# .github/workflows/map-lint.yml
- name: Validate MapLibre GL style
run: npx gl-style-validate style.json
- name: Visual regression — tile grid
run: npx playwright test tests/map-regression.spec.ts
Version tile styles independently from application code so styling hotfixes can be deployed without a full frontend release. Pair tile version strings with Scheduled Map Rebuild Workflows so that data updates trigger a coordinated tile regeneration and style cache bust.
Verification & Smoke-Test
After wiring up either pipeline, confirm the following:
- Tile requests succeed in DevTools Network tab. HTTP 200 responses with correct
Content-Type(image/webpfor raster;application/x-protobuforapplication/vnd.mapbox-vector-tile - No CORS errors. Tile servers must emit
Access-Control-Allow-Origin: * - Sustained 60 fps during pan.
- Feature hit-testing returns expected attributes. Click a feature and confirm the popup shows the correct property values from the
.pbf - WebP tiles load on Safari.
- Brotli compression active.
curl -sI -H 'Accept-Encoding: br' <tile-url> | grep content-encodingreturnsbr
Troubleshooting
Why are my vector tiles rendering blank even though the network requests succeed?
The tile source is loading, but the source-layer name in your style does not match the layer name encoded in the .pbf file. Vector tile layers have an explicit name set at generation time (the --layer flag in tippecanoe, or the table name in pg_tileserv). Inspect the actual layer names with vt-pbf or mvt-inspect:
npx mvt-inspect tile.pbf
# Output: Layer: districts (features: 2847)
Update your style’s source-layer value to match exactly — it is case-sensitive.
How do I recover from WebGL context loss without a full page reload?
Listen for the webglcontextrestored event on the canvas element and call map.triggerRepaint() to resume rendering. For severe losses where the context cannot be restored, show a user-facing message and call map.remove() before initMap() to fully reinitialise:
map.getCanvas().addEventListener('webglcontextrestored', () => {
map.triggerRepaint();
});
My raster tiles show seams or pixel offsets between adjacent tiles — what is wrong?
Seams between tiles almost always indicate a coordinate reference system mismatch. If source imagery is in EPSG:4326 but the tile grid is generated in EPSG:3857, neighbouring tiles will not align. Re-project the source raster before slicing:
gdalwarp -t_srs EPSG:3857 -r bilinear input_4326.tif input_3857.tif
gdal2tiles.py --profile mercator input_3857.tif output_tiles/
Also confirm that tileSize in your Leaflet or MapLibre GL config matches the pixel dimensions your tile server outputs (256 or 512 px).
Tile requests succeed but map performance degrades after several layer toggles — why?
Each call to map.addSource() and map.addLayer() allocates GPU buffers. Toggling layers by adding and removing them accumulates orphaned buffers. Instead, toggle layer visibility with map.setLayoutProperty(layerId, 'visibility', 'none'|'visible'), which preserves the buffer allocation and avoids GPU memory leaks. Remove layers and sources only when the entire dataset is no longer needed.
Why do my vector tile file sizes spike at zoom level 12–14?
At high zoom levels, tippecanoe includes every feature vertex without simplification. Use --simplification=4 (or higher) to reduce vertex count at those levels, and --minimum-feature-size=2 to drop sub-pixel features entirely. Validate the result:
tippecanoe --output=tiles/ --maximum-zoom=14 --simplification=4 \
--minimum-feature-size=2 input.geojson
Gotchas & Edge Cases
source-layeris case-sensitive and must exactly match the layer name embedded in the.pbffile. A single capital letter difference causes silent blank rendering with no console error.- MapLibre GL and Mapbox GL JS share a similar API but diverge on proprietary extension support. Styles authored for Mapbox GL JS v2+ may use features (e.g. globe projection, terrain exaggeration parameters) that MapLibre GL does not yet implement identically. Validate styles against the correct spec version.
- Leaflet’s default TMS
{y}coordinate is inverted relative to Google XYZ convention. If tiles appear vertically mirrored, use{-y}in the URL template or settms: truein theL.tileLayeroptions. - AVIF tile serving requires an explicit
Content-Type: image/avifheader. Some CDNs serve unknown extensions asapplication/octet-stream, causing browsers to reject the image silently. - WebP alpha channel support in Internet Explorer is non-existent. If your audience includes IE11 users, serve PNG tiles for the fallback branch.
- Vector tiles over HTTPS with a self-signed certificate fail silently in production. The
map.on('error')listener catchesnet::ERR_CERT_AUTHORITY_INVALIDbut the default error message is not user-friendly — add explicit TLS validation to your tile server health checks. - Calling
map.setStyle()to swap an entire style flushes all sources and layers, which causes a visual flash. For runtime theme changes, prefermap.setPaintProperty()andmap.setLayoutProperty()to mutate only the affected properties.
Related
- CRS & Projection Management — align coordinate systems before data enters the tile pipeline
- Base Layer Selection & Switching — orchestrate multiple tile sources and layer ordering
- Zoom/Pan Constraints & Boundaries — restrict viewport movement to a defined geographic area
- Cache Invalidation Strategies — coordinate tile cache busting with automated data refresh cycles
- How to Choose Between Raster Tiles and Vector Tiles for Web Dashboards — decision framework with hardware-profile scoring