| name | mapbox-web-performance-patterns |
| description | Performance optimization patterns for Mapbox GL JS web applications. Covers initialization waterfalls, bundle size, rendering performance, memory management, and web optimization. Prioritized by impact on user experience. |
Mapbox Performance Patterns Skill
This skill provides performance optimization guidance for building fast, efficient Mapbox applications. Patterns are prioritized by impact on user experience, starting with the most critical improvements.
Performance philosophy: These aren't micro-optimizations. They show up as waiting time, jank, and repeat costs that hit every user session.
Priority Levels
Performance issues are prioritized by their impact on user experience:
- 🔴 Critical (Fix First): Directly causes slow initial load or visible jank
- 🟡 High Impact: Noticeable delays or increased resource usage
- 🟢 Optimization: Incremental improvements for polish
🔴 Critical: Eliminate Initialization Waterfalls
Problem: Sequential loading creates cascading delays where each resource waits for the previous one.
Note: Modern bundlers (Vite, Webpack, etc.) and ESM dynamic imports automatically handle code splitting and library loading. The primary waterfall to eliminate is data loading - fetching map data sequentially instead of in parallel with map initialization.
Anti-Pattern: Sequential Data Loading
async function initMap() {
const map = new mapboxgl.Map({
container: 'map',
accessToken: MAPBOX_TOKEN,
style: 'mapbox://styles/mapbox/streets-v12'
});
map.on('load', async () => {
const data = await fetch('/api/data');
map.addSource('data', { type: 'geojson', data: await data.json() });
});
}
Timeline: Map init (0.5s) → Data fetch (1s) = 1.5s total
Solution: Parallel Data Loading
async function initMap() {
const dataPromise = fetch('/api/data').then((r) => r.json());
const map = new mapboxgl.Map({
container: 'map',
accessToken: MAPBOX_TOKEN,
style: 'mapbox://styles/mapbox/streets-v12'
});
map.on('load', async () => {
const data = await dataPromise;
map.addSource('data', { type: 'geojson', data });
map.addLayer({
id: 'data-layer',
type: 'circle',
source: 'data'
});
});
}
Timeline: Max(map init, data fetch) = ~1s total
Set Precise Initial Viewport
const map = new mapboxgl.Map({
container: 'map',
style: 'mapbox://styles/mapbox/streets-v12',
center: [-122.4194, 37.7749],
zoom: 13
});
map.once('idle', () => {
console.log('Initial viewport fully rendered');
});
If you know the exact area users will see first, setting center and zoom upfront avoids the map starting at a default view and then panning/zooming to the target, which wastes tile fetches.
Defer Non-Critical Features
const map = new mapboxgl.Map({
});
map.on('load', () => {
addCriticalLayers(map);
requestIdleCallback(
() => {
addTerrain(map);
addCustom3DLayers(map);
},
{ timeout: 2000 }
);
setTimeout(() => {
initializeAnalytics(map);
}, 3000);
});
Impact: Significant reduction in time-to-interactive, especially when deferring terrain and 3D layers
🔴 Critical: Optimize Initial Bundle Size
Problem: Large bundles delay time-to-interactive on slow networks.
Note: Modern bundlers (Vite, Webpack, etc.) automatically handle code splitting for framework-based applications. The guidance below is most relevant for optimizing what gets bundled and when.
Style JSON Bundle Impact
const style = {
version: 8,
sources: {
},
layers: [
]
};
const map = new mapboxgl.Map({
style: 'mapbox://styles/mapbox/streets-v12'
});
const map = new mapboxgl.Map({
style: '/styles/custom-style.json'
});
Impact: Reduces initial bundle by 30-50% when moving from inlined to hosted styles
🟡 High Impact: Optimize Marker Count
Problem: Too many markers causes slow rendering and interaction lag.
Performance Thresholds
- < 100 markers: HTML markers OK (Marker class)
- 100-10,000 markers: Use symbol layers (GPU-accelerated)
- 10,000+ markers: Clustering recommended
- 100,000+ markers: Vector tiles with server-side clustering
Anti-Pattern: Thousands of HTML Markers
restaurants.forEach((restaurant) => {
const marker = new mapboxgl.Marker()
.setLngLat([restaurant.lng, restaurant.lat])
.setPopup(new mapboxgl.Popup().setHTML(restaurant.name))
.addTo(map);
});
Result: 5,000 DOM elements, slow interactions, high memory
Solution: Use Symbol Layers (GeoJSON)
map.addSource('restaurants', {
type: 'geojson',
data: {
type: 'FeatureCollection',
features: restaurants.map((r) => ({
type: 'Feature',
geometry: { type: 'Point', coordinates: [r.lng, r.lat] },
properties: { name: r.name, type: r.type }
}))
}
});
map.addLayer({
id: 'restaurants',
type: 'symbol',
source: 'restaurants',
layout: {
'icon-image': 'restaurant',
'icon-size': 0.8,
'text-field': ['get', 'name'],
'text-size': 12,
'text-offset': [0, 1.5],
'text-anchor': 'top'
}
});
map.on('click', 'restaurants', (e) => {
const feature = e.features[0];
new mapboxgl.Popup().setLngLat(feature.geometry.coordinates).setHTML(feature.properties.name).addTo(map);
});
Performance: 10,000 features render in <100ms
Solution: Clustering for High Density
map.addSource('restaurants', {
type: 'geojson',
data: restaurantsGeoJSON,
cluster: true,
clusterMaxZoom: 14,
clusterRadius: 50
});
map.addLayer({
id: 'clusters',
type: 'circle',
source: 'restaurants',
filter: ['has', 'point_count'],
paint: {
'circle-color': ['step', ['get', 'point_count'], '#51bbd6', 100, '#f1f075', 750, '#f28cb1'],
'circle-radius': ['step', ['get', 'point_count'], 20, 100, 30, 750, 40]
}
});
map.addLayer({
id: 'cluster-count',
type: 'symbol',
source: 'restaurants',
filter: ['has', 'point_count'],
layout: {
'text-field': '{point_count_abbreviated}',
'text-size': 12
}
});
map.addLayer({
id: 'unclustered-point',
type: 'circle',
source: 'restaurants',
filter: ['!', ['has', 'point_count']],
paint: {
'circle-color': '#11b4da',
'circle-radius': 6
}
});
Impact: 50,000 markers at 60 FPS with smooth interaction
🟡 High Impact: Optimize Data Loading Strategy
Problem: Loading all data upfront wastes bandwidth and slows initial render.
GeoJSON vs Vector Tiles Decision Matrix
| Scenario | Use GeoJSON | Use Vector Tiles |
|---|
| < 5 MB data | Yes | No |
| 5-20 MB data | Consider | Yes |
| > 20 MB data | No | Yes |
| Data changes frequently | Yes | No |
| Static data, global scale | No | Yes |
| Need server-side updates | No | Yes |
Viewport-Based Loading (GeoJSON)
Note: This pattern is applicable when hosting GeoJSON data locally or on external servers. Mapbox-hosted data sources are already optimized for viewport-based loading.
async function loadVisibleData(map) {
const bounds = map.getBounds();
const bbox = [bounds.getWest(), bounds.getSouth(), bounds.getEast(), bounds.getNorth()].join(',');
const data = await fetch(`/api/data?bbox=${bbox}&zoom=${map.getZoom()}`);
map.getSource('data').setData(await data.json());
}
let timeout;
map.on('moveend', () => {
clearTimeout(timeout);
timeout = setTimeout(() => loadVisibleData(map), 300);
});
Important: setData() triggers a full re-parse of the GeoJSON in a web worker. For small datasets updated frequently, consider using source.updateData() (requires dynamic: true on the source) for partial updates. For large datasets, switch to vector tiles.
Progressive Data Loading
Note: This pattern is applicable when hosting GeoJSON data locally or on external servers.
async function loadDataProgressive(map) {
const simplified = await fetch('/api/data?detail=low');
map.addSource('data', {
type: 'geojson',
data: await simplified.json()
});
addLayers(map);
const detailed = await fetch('/api/data?detail=high');
map.getSource('data').setData(await detailed.json());
}
Vector Tiles for Large Datasets
Note: The minzoom/maxzoom optimization shown below is primarily for self-hosted vector tilesets. Mapbox-hosted tilesets have built-in optimization via Mapbox Tiling Service (MTS) recipes that handle zoom-level optimizations automatically.
map.addSource('large-dataset', {
type: 'vector',
tiles: ['https://api.example.com/tiles/{z}/{x}/{y}.pbf'],
minzoom: 0,
maxzoom: 14
});
map.addLayer({
id: 'large-dataset-layer',
type: 'fill',
source: 'large-dataset',
'source-layer': 'data',
paint: {
'fill-color': '#088',
'fill-opacity': 0.6
}
});
Impact: 10 MB dataset reduced to ~500 KB per viewport load
🟡 High Impact: Optimize Map Interactions
Problem: Unthrottled event handlers cause performance degradation.
Anti-Pattern: Expensive Operations on Every Event
map.on('move', () => {
updateVisibleFeatures();
fetchDataFromAPI();
updateUI();
});
Solution: Debounce/Throttle Events
let throttleTimeout;
map.on('move', () => {
if (throttleTimeout) return;
throttleTimeout = setTimeout(() => {
updateMapCenter();
throttleTimeout = null;
}, 100);
});
map.on('moveend', () => {
updateVisibleFeatures();
fetchDataFromAPI();
updateUI();
});
Optimize Feature Queries
map.on('click', (e) => {
const features = map.queryRenderedFeatures(e.point);
console.log(features);
});
map.on('click', (e) => {
const features = map.queryRenderedFeatures(e.point, {
layers: ['restaurants', 'shops']
});
if (features.length > 0) {
showPopup(features[0]);
}
});
map.on('click', (e) => {
const bbox = [
[e.point.x - 5, e.point.y - 5],
[e.point.x + 5, e.point.y + 5]
];
const features = map.queryRenderedFeatures(bbox, {
layers: ['restaurants'],
filter: ['==', ['get', 'type'], 'pizza']
});
});
Batch DOM Updates
map.on('mousemove', 'restaurants', (e) => {
e.features.forEach((feature) => {
document.getElementById(feature.id).classList.add('highlight');
});
});
let pendingUpdates = new Set();
let rafScheduled = false;
map.on('mousemove', 'restaurants', (e) => {
e.features.forEach((f) => pendingUpdates.add(f.id));
if (!rafScheduled) {
rafScheduled = true;
requestAnimationFrame(() => {
pendingUpdates.forEach((id) => {
document.getElementById(id).classList.add('highlight');
});
pendingUpdates.clear();
rafScheduled = false;
});
}
});
Impact: 60 FPS maintained during interaction vs 15-20 FPS without optimization
🟡 High Impact: Memory Management
Problem: Memory leaks cause browser tabs to become unresponsive over time. In SPAs that create/destroy map instances, this is a common production issue.
Always Clean Up Map Resources
function cleanupMap(map) {
if (!map) return;
map.off('load', handleLoad);
map.off('move', handleMove);
if (map.getLayer('dynamic-layer')) {
map.removeLayer('dynamic-layer');
}
if (map.getSource('dynamic-source')) {
map.removeSource('dynamic-source');
}
map.removeControl(navigationControl);
map.remove();
}
useEffect(() => {
const map = new mapboxgl.Map({
});
return () => {
cleanupMap(map);
};
}, []);
Clean Up Popups and Markers
map.on('click', 'restaurants', (e) => {
new mapboxgl.Popup().setLngLat(e.lngLat).setHTML(e.features[0].properties.name).addTo(map);
});
let popup = new mapboxgl.Popup({ closeOnClick: true });
map.on('click', 'restaurants', (e) => {
popup.setLngLat(e.lngLat).setHTML(e.features[0].properties.name).addTo(map);
});
function cleanup() {
popup.remove();
popup = null;
}
Use Feature State Instead of New Layers
let hoveredFeatureId = null;
map.on('mousemove', 'restaurants', (e) => {
if (map.getLayer('hover-layer')) {
map.removeLayer('hover-layer');
}
map.addLayer({
id: 'hover-layer',
type: 'circle',
source: 'restaurants',
filter: ['==', ['id'], e.features[0].id],
paint: { 'circle-color': 'yellow' }
});
});
map.on('mousemove', 'restaurants', (e) => {
if (e.features.length > 0) {
if (hoveredFeatureId !== null) {
map.setFeatureState({ source: 'restaurants', id: hoveredFeatureId }, { hover: false });
}
hoveredFeatureId = e.features[0].id;
map.setFeatureState({ source: 'restaurants', id: hoveredFeatureId }, { hover: true });
}
});
map.addLayer({
id: 'restaurants',
type: 'circle',
source: 'restaurants',
paint: {
'circle-color': [
'case',
['boolean', ['feature-state', 'hover'], false],
'#ffff00',
'#0000ff'
]
}
});
Note: Feature state requires features to have IDs. Use generateId: true on the GeoJSON source to auto-assign IDs, or use promoteId to use an existing property as the feature ID.
Impact: Prevents memory growth from continuous layer churn over long sessions
🟢 Optimization: Mobile Performance
Problem: Mobile devices have limited resources (CPU, GPU, memory, battery).
Mobile-Specific Optimizations
const isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent);
const map = new mapboxgl.Map({
container: 'map',
style: 'mapbox://styles/mapbox/streets-v12',
...(isMobile && {
maxZoom: 18,
fadeDuration: 0
})
});
map.on('load', () => {
if (isMobile) {
map.addLayer({
id: 'markers-mobile',
type: 'circle',
source: 'data',
paint: {
'circle-radius': 8,
'circle-color': '#007cbf'
}
});
} else {
map.addLayer({
id: 'markers-desktop',
type: 'symbol',
source: 'data',
layout: {
'icon-image': 'marker',
'icon-size': 1,
'text-field': ['get', 'name'],
'text-size': 12,
'text-offset': [0, 1.5]
}
});
}
});
Touch Interaction Optimization
map.touchZoomRotate.disableRotation();
let touchTimeout;
map.on('touchmove', () => {
if (touchTimeout) clearTimeout(touchTimeout);
touchTimeout = setTimeout(() => {
updateVisibleData();
}, 500);
});
Performance-Sensitive Constructor Options
const map = new mapboxgl.Map({
container: 'map',
style: 'mapbox://styles/mapbox/streets-v12',
preserveDrawingBuffer: false,
antialias: false
});
🟢 Optimization: Layer and Style Performance
Consolidate Layers
restaurantTypes.forEach((type) => {
map.addLayer({
id: `restaurants-${type}`,
type: 'symbol',
source: 'restaurants',
filter: ['==', ['get', 'type'], type],
layout: { 'icon-image': `${type}-icon` }
});
});
map.addLayer({
id: 'restaurants',
type: 'symbol',
source: 'restaurants',
layout: {
'icon-image': [
'match',
['get', 'type'],
'pizza',
'pizza-icon',
'burger',
'burger-icon',
'sushi',
'sushi-icon',
'default-icon'
]
}
});
Impact: Fewer layers means less rendering overhead. Each layer has fixed per-layer cost regardless of feature count.
Simplify Expressions for Large Datasets
For datasets with 100,000+ features, simpler expressions reduce per-feature evaluation cost. For smaller datasets, the expression engine is fast enough that this won't be noticeable.
map.addLayer({
id: 'buildings',
type: 'fill-extrusion',
source: 'buildings',
paint: {
'fill-extrusion-color': ['interpolate', ['linear'], ['get', 'height'], 0, '#dedede', 50, '#a0a0a0', 100, '#606060'],
'fill-extrusion-height': [
'step',
['zoom'],
['get', 'height'],
16,
['*', ['get', 'height'], 1.5]
]
}
});
For very large GeoJSON datasets, pre-computing static property derivations (like color categories) into the source data can reduce per-feature expression work:
const buildingsWithColor = {
type: 'FeatureCollection',
features: buildings.features.map((f) => ({
...f,
properties: {
...f.properties,
heightColor: getColorForHeight(f.properties.height)
}
}))
};
map.addSource('buildings', { type: 'geojson', data: buildingsWithColor });
map.addLayer({
id: 'buildings',
type: 'fill-extrusion',
source: 'buildings',
paint: {
'fill-extrusion-color': ['get', 'heightColor'],
'fill-extrusion-height': ['get', 'height']
}
});
Use Zoom-Based Layer Visibility
map.addLayer({
id: 'building-details',
type: 'fill',
source: 'buildings',
minzoom: 15,
paint: { 'fill-color': '#aaa' }
});
map.addLayer({
id: 'poi-labels',
type: 'symbol',
source: 'pois',
minzoom: 12,
layout: {
'text-field': ['get', 'name'],
visibility: 'visible'
}
});
Note: minzoom is inclusive (layer visible at that zoom), maxzoom is exclusive (layer hidden at that zoom). A layer with maxzoom: 16 is visible up to but not including zoom 16.
Impact: Reduces GPU work at zoom levels where layers aren't useful
Summary: Performance Checklist
When building a Mapbox application, verify these optimizations in order:
🔴 Critical (Do First)
🟡 High Impact
🟢 Optimization
Measurement
console.time('map-load');
map.on('load', () => {
console.timeEnd('map-load');
console.log('Style loaded:', map.isStyleLoaded());
});
let frameCount = 0;
map.on('render', () => frameCount++);
setInterval(() => {
console.log('FPS:', frameCount);
frameCount = 0;
}, 1000);
Target metrics:
- Time to Interactive: < 2 seconds on 3G
- Frame Rate: 60 FPS during pan/zoom
- Memory Growth: < 10 MB per hour of usage
- Bundle Size: < 500 KB initial (map lazy-loaded)