How to Choose Between Raster Tiles and Vector Tiles for Web Dashboards

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.

Raster vs Vector Tile Rendering Pipeline Two parallel pipelines: raster tiles render on the server then deliver a finished image; vector tiles deliver compressed geometry and render on the client GPU. RASTER TILES VECTOR TILES GeoData Source Server Renderer (Mapnik / GDAL) PNG/JPEG Tile Cache (CDN / S3) Browser (image decode only) Render: SERVER GeoData Source Tile Generator (tippecanoe / pg_tileserv) Protobuf Tile Cache (.mvt / CDN) Browser (WebGL GPU render) Render: CLIENT GPU

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 (or application/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=86400 is the minimum for effective CDN caching. Vector tiles serving live data should use Cache-Control: no-cache or a short max-age matching 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.