Chapter 19 · Mapbox Web Performance Patterns
Subchapter 19.6
AGENTS.mdMarkdown6 KBView on GitHub
Quick reference for optimizing Mapbox GL JS applications. Prioritized by impact: 🔴 Critical → 🟡 High Impact → 🟢 Optimization.
Impact: Saves 500ms-2s on initial load
Problem: Sequential loading (map → data → render) Solution: Parallel data fetching
// ❌ Sequential: 1.5s total
map.on('load', async () => {
const data = await fetch('/api/data'); // Waits for map first
});
// ✅ Parallel: ~1s total
const dataPromise = fetch('/api/data'); // Starts immediately
const map = new mapboxgl.Map({...});
map.on('load', async () => {
const data = await dataPromise; // Already fetching
});Key principle: Start all data fetches immediately, don’t wait for map load.
Impact: 200-500KB savings, faster load times
Critical actions:
const geocoder = await import('mapbox-gl-geocoder')Size targets: <500KB initial bundle, <200KB per route
Impact: Smooth rendering with many markers
Decision tree:
new mapboxgl.Marker()) - OK// ✅ For 100+ markers: Use symbol layer, not HTML markers
map.addLayer({
id: 'points',
type: 'symbol',
source: 'points',
layout: { 'icon-image': 'marker' }
});
// ✅ For 10,000+ markers: Add clustering
map.addSource('points', {
type: 'geojson',
data: geojson,
cluster: true,
clusterRadius: 50 // Relative to tile dimensions (512 = full tile width)
});Impact: Faster rendering, lower memory
Decision tree:
Viewport-based loading pattern:
map.on('moveend', () => {
const bounds = map.getBounds();
fetchDataInBounds(bounds).then((data) => {
map.getSource('data').setData(data);
});
});Warning: setData() triggers a full re-parse in a web worker. For small datasets updated frequently, use source.updateData() (requires dynamic: true) for partial updates. For large datasets, switch to vector tiles.
Impact: Prevents jank during interactions
Rules:
once() for one-time events// ✅ Debounce expensive operations
const debouncedSearch = debounce((query) => {
geocode(query);
}, 300);
// ✅ Throttle frequent events
const throttledUpdate = throttle(() => {
updateAnalytics(map.getCenter());
}, 100);Critical for SPAs and long-running apps
Always cleanup on unmount:
// ✅ Remove map and all resources
map.remove(); // Removes all event listeners, sources, layers
// ✅ Cancel pending requests
controller.abort();
// ✅ Clear references
markers.forEach((m) => m.remove());
markers = [];Rules:
map.once('idle', callback) after multiple changesKey patterns:
maxzoom on sources to avoid over-fetching tilesgenerateId: true on GeoJSON sources to enable feature state (auto-assigns feature IDs)promoteId to use an existing data property as the feature ID (alternative to generateId)'icon-allow-overlap': true AND 'icon-ignore-placement': true (plus text equivalents if using text)preserveDrawingBuffer or antialias unless specifically neededSlow initial load? → Check for waterfalls (data loading), optimize bundle size
Jank with many markers? → Switch to symbol layers + clustering at 100+ markers
Memory leaks in SPA? → Add proper cleanup (map.remove())
Slow with large data? → Use vector tiles, viewport loading
Sluggish interactions? → Debounce/throttle event handlers
High memory usage? → Use feature state instead of layer churn, check for listener leaks
Measure what matters:
Key API for measurement: map.isStyleLoaded() returns true when the style and all resources are fully loaded. Use map.once('idle') to detect when all rendering is complete.
Tools: Chrome DevTools Performance tab, Lighthouse, Bundle analyzers (webpack-bundle-analyzer, vite-bundle-visualizer)
map.remove() in SPAssetData() frequently on large GeoJSON sources (use vector tiles instead)