Rendering Large Geospatial Datasets with Mapbox GL JS Vector Tile Layers
How to render large geospatial datasets in the browser with Mapbox GL JS vector tiles: a worked addSource/addLayer configuration, zoom‑dependent styling, runtime filters, and the silent failures that cause blank maps.
28 Feb 2026, 21:31 UTC

The short answer
If you need to display tens or hundreds of thousands of geographic features in a browser, don't ship GeoJSON to the client. Upload the data to Mapbox as a vector tileset, add it with map.addSource(), and style it with map.addLayer(). The browser then downloads only the tiles covering the current viewport and zoom level, and styling lives entirely in the style JSON — you can recolor, filter, or restyle features without touching the underlying data.
This article walks through a working configuration, explains how the pieces fit together, and covers the mistakes that most often leave developers staring at a blank map.
Why vector tiles beat client‑side GeoJSON
Loading a large GeoJSON file means the browser downloads every feature up front, parses the whole thing, and holds it in memory regardless of what the user is looking at. A 50 MB dataset of building footprints is a 50 MB download even if the user never zooms past the city level.
Vector tiles invert this. The dataset is pre‑sliced into small protobuf‑encoded tiles (.pbf, historically .mvt) at multiple zoom levels. As the user pans and zooms, Mapbox GL JS requests only the tiles intersecting the viewport. Features are also generalized per zoom level, so a zoomed‑out view carries simplified geometry instead of every vertex. The trade‑off: updates require regenerating or re‑uploading the tileset, so this pattern suits relatively static data, not real‑time feeds.
A worked configuration
Assume you have uploaded a dataset to Mapbox Studio (or via the Uploads/Tilesets API) and have a tileset ID like youruser.buildings-abc123, containing a source layer named footprints with a numeric attribute height_m. The following runs in the browser after the map's load event fires — no special permissions beyond a valid Mapbox access token:
map.on('load', () => {
map.addSource('buildings', {
type: 'vector',
url: 'mapbox://youruser.buildings-abc123'
});
map.addLayer({
id: 'building-footprints',
type: 'fill',
source: 'buildings',
'source-layer': 'footprints',
minzoom: 13,
paint: {
'fill-color': [
'interpolate', ['linear'], ['zoom'],
13, '#9ecae1',
16, '#3182bd'
],
'fill-opacity': 0.7
}
});
map.addLayer({
id: 'tall-buildings',
type: 'line',
source: 'buildings',
'source-layer': 'footprints',
minzoom: 13,
filter: ['>=', ['get', 'height_m'], 50],
paint: {
'line-color': '#de2d26',
'line-width': [
'interpolate', ['linear'], ['zoom'],
13, 0.5,
16, 2
]
}
});
});Three mechanisms are doing the work here:
- Source vs. layer separation.
addSourceregisters the tileset;addLayerdescribes rendering. One source can back many layers — above, the same tileset drives both a fill layer and a line layer highlighting tall buildings. - Zoom‑dependent styling. The
interpolateexpression with zoom stops transitions color and line width smoothly as the user zooms. This replaces the olderstopsobject syntax and works in current Mapbox GL JS v2/v3. - Attribute filtering. The
filterarray evaluates per feature, client‑side. Only buildings withheight_m >= 50get the red outline. You can change this at runtime withmap.setFilter('tall-buildings', newFilter)without reloading any data.
Verifying it actually works
Don't trust that the layer exists — check it:
- Open browser developer tools, Network tab, and filter for
.pbfor.mvt. You should see tile requests fire as you pan. No requests means the source never registered or the token lacks access to the tileset. - Zoom to at least
minzoom(13 in the example) over an area you know contains data. - In the console, run
map.getLayer('building-footprints')— it should return the layer object, notundefined. - Run
map.queryRenderedFeatures({ layers: ['building-footprints'] })while looking at a data‑covered area. An empty array with tiles loading usually means asource-layername mismatch.
The mistakes that cause blank maps
Wrong source-layer name. This is the single most common failure. The source-layer is the layer name inside the vector tile, not your tileset ID and not the layer id you chose. Find the real name in Mapbox Studio's tileset details page, or inspect it programmatically: map.getSource('buildings').vectorLayerIds after the source loads. A mismatch fails silently — the source loads, no errors appear, and nothing renders.
Adding layers before the style loads. Calling addSource outside the load event (or before styledata settles after a setStyle call) throws or silently drops your layer. If you switch base styles at runtime, you must re‑add all custom sources and layers afterward — setStyle wipes them unless you use the diff: false option carefully.
Filter‑heavy layer sprawl. Filters are cheap individually, but stacking many layers with complex expressions forces the renderer to evaluate every feature in every tile against every filter on each frame. If you find yourself with a dozen near‑identical layers differing only by filter, consolidate: use a single layer with a match or case expression in the paint property instead. That evaluates once per feature rather than once per layer per feature.
Rendering everything at low zoom. Setting minzoom: 0 on a dense dataset makes the client download and draw enormous tile counts. Set minzoom to the level where individual features become meaningful, and consider a low‑zoom heatmap or clustered circle layer as the overview, swapping to detailed fills at higher zoom.
Limits to plan around
Vector tile rendering is client‑side, so the user's device does the work. Very dense tiles (hundreds of kilobytes each) will stutter on low‑end mobile devices regardless of how clean your style is. Keep individual tile sizes down by dropping unnecessary attributes at upload time — every property on every feature ships to the browser.
Tile requests also count against your Mapbox usage quotas. A map that triggers heavy panning across a large dataset generates many requests per session; check your pricing tier's tile request limits and consider restricting the map's maxBounds if users only need a specific region. Quota behavior and current GL JS version specifics change over time, so confirm against Mapbox's current documentation before shipping.
Finally, remember the data freshness constraint: vector tiles are baked artifacts. If your dataset updates hourly, you need a tileset regeneration pipeline (Mapbox Tiling Service recipes handle this) or a different architecture for the dynamic portion.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.