
Mapbox Web Performance Patterns
- 1.7k installs
- 71 repo stars
- Updated August 4, 2026
- mapbox/mapbox-agent-skills
How to achieve sub-2-second time-to-interactive and 60 FPS performance in Mapbox GL JS applications by eliminating initialization waterfalls, optimizing bundles, and using GPU-accelerated rendering instead of HTML marker
About
This skill provides patterns for optimizing Mapbox GL JS performance across critical workflows: eliminating initialization waterfalls by parallelizing data loading with map setup, reducing bundle size by externalizing style JSON, and optimizing marker rendering via symbol layers and clustering instead of HTML markers. Developers use it when building map-heavy applications to hit targets like sub-2-second time-to-interactive on 3G and 60 FPS pan/zoom. Patterns prioritize by impact: critical waterfalls and bundle bloat first, then marker optimization, then interactions and memory cleanup.
- Eliminate initialization waterfalls by fetching data in parallel with map setup instead of sequential loading
- Reduce bundle 30-50% by referencing Mapbox-hosted styles instead of inlining massive style JSON
- Use GPU-accelerated symbol layers for 100-10,000 markers instead of HTML marker DOM elements
- Implement clustering for 10,000+ markers to maintain 60 FPS interaction and smooth pan/zoom
- Defer non-critical features (terrain, 3D layers, analytics) until after initial viewport renders
Mapbox Web Performance Patterns by the numbers
- 1,740 all-time installs (skills.sh)
- +98 installs in the week ending Aug 5, 2026 (Skillselion tracking)
- Ranked #273 of 2,245 Frontend Development skills by installs in the Skillselion catalog
- Security screen: MEDIUM risk (skills.sh audit)
- Data as of Aug 5, 2026 (Skillselion catalog sync)
mapbox-web-performance-patterns capabilities & compatibility
- Capabilities
- eliminate initialization waterfalls via parallel · optimize bundle size by externalizing styles and · render 10,000+ markers efficiently with symbol l · measure performance (fps, load time, memory grow · implement viewport based data loading for large
- Use cases
- frontend
- Platforms
- macOS · Windows · Linux
- Runs
- Runs locally
- Pricing
- Free
What mapbox-web-performance-patterns says it does
Reduces initial bundle by 30-50% when moving from inlined to hosted styles
npx skills add https://github.com/mapbox/mapbox-agent-skills --skill mapbox-web-performance-patternsAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 1.7k |
|---|---|
| repo stars | ★ 71 |
| Security audit | 2 / 3 scanners passed |
| Last updated | August 4, 2026 |
| Repository | mapbox/mapbox-agent-skills ↗ |
What it does
Optimize Mapbox GL JS web application performance across initialization, bundle size, rendering, and memory management.
Who is it for?
Building performant map-heavy web applications (real estate platforms, delivery tracking, geospatial dashboards) where initial load time and interaction smoothness directly impact user retention.
Skip if: Static map embeds, simple map displays with < 20 markers, or applications where sub-2-second load is not a business requirement.
When should I use this skill?
Designing or refactoring a Mapbox feature; profiling slow initial load or janky pan/zoom; planning marker rendering strategy for 100+ features; preparing for production launch.
What you get
Developers ship Mapbox apps that load in < 2s on 3G, render at 60 FPS during pan/zoom, handle 10,000+ markers smoothly via clustering, and grow < 10 MB memory per hour.
- Optimized initialization code with parallel data loading
- Symbol layer implementation for markers
- Clustering configuration for high-density datasets
By the numbers
- Style JSON inlining adds 500+ KB; external reference reduces initial bundle 30-50%
- Sequential data loading: Map init (0.5s) + Data fetch (1s) = 1.5s; parallel execution = ~1s max
- Clustering handles 50,000 markers at 60 FPS vs < 100ms render for 10,000 symbol features
Files
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
// ❌ BAD: Data loads AFTER map initializes
async function initMap() {
const map = new mapboxgl.Map({
container: 'map',
accessToken: MAPBOX_TOKEN,
style: 'mapbox://styles/mapbox/streets-v12'
});
// Wait for map to load, THEN fetch data
map.on('load', async () => {
const data = await fetch('/api/data'); // Waterfall!
map.addSource('data', { type: 'geojson', data: await data.json() });
});
}Timeline: Map init (0.5s) → Data fetch (1s) = 1.5s total
Solution: Parallel Data Loading
// ✅ GOOD: Data fetch starts immediately
async function initMap() {
// Start data fetch immediately (don't wait for map)
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'
});
// Data is ready when map loads
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
// ✅ Set exact center/zoom so the map fetches the right tiles immediately
const map = new mapboxgl.Map({
container: 'map',
style: 'mapbox://styles/mapbox/streets-v12',
center: [-122.4194, 37.7749],
zoom: 13
});
// Use 'idle' to know when the initial viewport is fully rendered
// (all tiles, sprites, and other resources are loaded; no transitions in progress)
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
// ✅ Load critical features first, defer others
const map = new mapboxgl.Map({
/* config */
});
map.on('load', () => {
// 1. Add critical layers immediately
addCriticalLayers(map);
// 2. Defer secondary features
// Note: Standard style 3D buildings can be toggled via config:
// map.setConfigProperty('basemap', 'show3dObjects', false);
requestIdleCallback(
() => {
addTerrain(map);
addCustom3DLayers(map); // For classic styles with custom fill-extrusion layers
},
{ timeout: 2000 }
);
// 3. Defer analytics and non-visual features
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
// ❌ BAD: Inline massive style JSON (can be 500+ KB)
const style = {
version: 8,
sources: {
/* 100s of lines */
},
layers: [
/* 100s of layers */
]
};
// ✅ GOOD: Reference Mapbox-hosted styles
const map = new mapboxgl.Map({
style: 'mapbox://styles/mapbox/streets-v12' // Fetched on demand
});
// ✅ OR: Store large custom styles externally
const map = new mapboxgl.Map({
style: '/styles/custom-style.json' // Loaded separately
});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
// ❌ BAD: 5,000 HTML markers = 5+ second render, janky pan/zoom
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)
// ✅ GOOD: GPU-accelerated rendering, smooth at 10,000+ features
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'
}
});
// Click handler (one listener for all features)
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
// ✅ GOOD: 50,000 markers → ~500 clusters at low zoom
map.addSource('restaurants', {
type: 'geojson',
data: restaurantsGeoJSON,
cluster: true,
clusterMaxZoom: 14, // Stop clustering at zoom 15
clusterRadius: 50 // Radius relative to tile dimensions (512 = full tile width)
});
// Cluster circle layer
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]
}
});
// Cluster count label
map.addLayer({
id: 'cluster-count',
type: 'symbol',
source: 'restaurants',
filter: ['has', 'point_count'],
layout: {
'text-field': '{point_count_abbreviated}',
'text-size': 12
}
});
// Individual point layer
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
---
Summary: Performance Checklist
When building a Mapbox application, verify these optimizations in order:
🔴 Critical (Do First)
- [ ] Load map library and data in parallel (eliminate waterfalls)
- [ ] Use dynamic imports for map code (reduce initial bundle)
- [ ] Defer non-critical features (terrain, custom 3D layers, analytics)
- [ ] Use symbol layers for > 100 markers (not HTML markers)
- [ ] Implement viewport-based data loading for large datasets
🟡 High Impact
- [ ] Debounce/throttle map event handlers
- [ ] Optimize queryRenderedFeatures with layers filter and bounding box
- [ ] Use GeoJSON for < 5 MB, vector tiles for > 20 MB
- [ ] Always call map.remove() on cleanup in SPAs
- [ ] Reuse popup instances (don't create on every interaction)
- [ ] Use feature state instead of dynamic layers for hover/selection
🟢 Optimization
- [ ] Consolidate multiple layers with data-driven styling
- [ ] Add mobile-specific optimizations (circle layers, disabled rotation)
- [ ] Set minzoom/maxzoom on layers to avoid rendering at irrelevant zoom levels
- [ ] Avoid enabling preserveDrawingBuffer or antialias unless needed
Measurement
// Measure initial load time
console.time('map-load');
map.on('load', () => {
console.timeEnd('map-load');
// isStyleLoaded() returns true when style, sources, tiles, sprites, and models are all loaded
console.log('Style loaded:', map.isStyleLoaded());
});
// Monitor frame rate
let frameCount = 0;
map.on('render', () => frameCount++);
setInterval(() => {
console.log('FPS:', frameCount);
frameCount = 0;
}, 1000);
// Check memory usage (Chrome DevTools -> Performance -> Memory)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)
---
Reference Files
For detailed patterns on specific topics, load the corresponding reference file:
- `references/data-loading.md` — GeoJSON vs Vector Tiles decision matrix, viewport-based loading, progressive loading, vector tiles for large datasets
- `references/interactions.md` — Debounce/throttle events, optimize feature queries, batch DOM updates
- `references/memory.md` — Map cleanup patterns, popup/marker reuse, feature state vs dynamic layers
- `references/mobile.md` — Device detection, mobile-optimized layers, touch interaction, constructor options
- `references/layers-styles.md` — Consolidate layers with data-driven styling, simplify expressions, zoom-based visibility
Mapbox GL JS Performance Optimization Guide
Quick reference for optimizing Mapbox GL JS applications. Prioritized by impact: 🔴 Critical → 🟡 High Impact → 🟢 Optimization.
🔴 Critical Performance Patterns (Fix First)
1. Eliminate Initialization Waterfalls
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.
2. Bundle Size Optimization
Impact: 200-500KB savings, faster load times
Critical actions:
- Use dynamic imports for large features:
const geocoder = await import('mapbox-gl-geocoder') - Code-split by route/feature
- Avoid importing entire Mapbox GL JS if only using specific features
- Use CSS splitting for mapbox-gl.css
Size targets: <500KB initial bundle, <200KB per route
🟡 High Impact Patterns
3. Marker Performance
Impact: Smooth rendering with many markers
Decision tree:
- < 100 markers: HTML markers (
new mapboxgl.Marker()) - OK - 100-10,000 markers: Symbol layers - GPU-accelerated, much faster
- 10,000+ markers: Symbol layers + clustering required
- 100,000+ markers: Vector tiles with server-side clustering
// ✅ 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)
});4. Data Loading Strategy
Impact: Faster rendering, lower memory
Decision tree:
- < 5MB GeoJSON: Load directly as GeoJSON source
- > 5MB GeoJSON: Use vector tiles instead
- Dynamic data: Implement viewport-based loading
- Static data: Embed small datasets, fetch large ones
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.
5. Event Handler Optimization
Impact: Prevents jank during interactions
Rules:
- Debounce search/geocoding: 300ms minimum
- Throttle move/zoom events: 100ms for analytics, 16ms for UI updates (move fires ~60fps)
- Use
once()for one-time events - Remove event listeners on cleanup
// ✅ Debounce expensive operations
const debouncedSearch = debounce((query) => {
geocode(query);
}, 300);
// ✅ Throttle frequent events
const throttledUpdate = throttle(() => {
updateAnalytics(map.getCenter());
}, 100);6. Memory Management
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 = [];🟢 Optimization Patterns
7. Layer Management
Rules:
- Use feature state instead of removing/re-adding layers for hover/selection
- Batch style changes: Use
map.once('idle', callback)after multiple changes - Hide layers with visibility: 'none' instead of removing
- Minimize layer count: Combine similar layers with data-driven styling where possible
8. Rendering Optimization
Key patterns:
- Set
maxzoomon sources to avoid over-fetching tiles - Use
generateId: trueon GeoJSON sources to enable feature state (auto-assigns feature IDs) - Use
promoteIdto use an existing data property as the feature ID (alternative to generateId) - To fully skip collision work on a symbol layer, set BOTH
'icon-allow-overlap': trueAND'icon-ignore-placement': true(plus text equivalents if using text) - Avoid enabling
preserveDrawingBufferorantialiasunless specifically needed
Quick Decision Guide
Slow 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
Performance Testing
Measure what matters:
- Time to Interactive (TTI): < 2s on 3G
- First Contentful Paint (FCP): < 1s
- Bundle size: < 500KB initial
- Memory: Stable over time (no leaks)
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)
Anti-Patterns to Avoid
- Loading data after map initialization (waterfall)
- Using HTML markers for 100+ points
- Not clustering 10,000+ markers
- Loading entire GeoJSON files > 5MB without vector tiles
- Not debouncing search/geocoding
- Forgetting to call
map.remove()in SPAs - Adding/removing layers frequently (use feature state)
- Not code-splitting large features
- Calling
setData()frequently on large GeoJSON sources (use vector tiles instead)
{
"skill_name": "mapbox-web-performance-patterns",
"evals": [
{
"id": 1,
"prompt": "Review this Mapbox initialization code and tell me what's wrong with it:\n\n```javascript\nasync function initMap() {\n const map = new mapboxgl.Map({\n container: 'map',\n style: 'mapbox://styles/mapbox/streets-v12',\n center: [-122.4194, 37.7749],\n zoom: 12\n });\n\n map.on('load', async () => {\n const response = await fetch('/api/restaurants');\n const data = await response.json();\n\n map.addSource('restaurants', { type: 'geojson', data });\n map.addLayer({\n id: 'restaurants',\n type: 'circle',\n source: 'restaurants',\n paint: { 'circle-color': '#ff0000' }\n });\n });\n\n map.on('click', (e) => {\n const features = map.queryRenderedFeatures(e.point);\n if (features.length > 0) {\n new mapboxgl.Popup()\n .setLngLat(e.lngLat)\n .setHTML(features[0].properties.name)\n .addTo(map);\n }\n });\n\n map.on('move', () => {\n updateVisibleCount(map);\n });\n}\n```",
"expected_output": "Should identify four performance issues: (1) data fetch inside map.on('load') creates a loading waterfall — the fetch should start before map initialization so both run in parallel; (2) new mapboxgl.Popup() created on every click causes a memory leak — a single popup instance should be declared outside the handler and reused; (3) queryRenderedFeatures(e.point) without a layers filter queries all rendered features which is expensive — should pass { layers: ['restaurants'] }; (4) expensive updateVisibleCount() called on every 'move' event which fires ~60 times/sec during pan — should use 'moveend' instead, or throttle.",
"files": [],
"expectations": [
"Identifies the data fetch waterfall: fetch inside map.on('load') runs sequentially after map init instead of in parallel",
"Recommends starting the fetch before new mapboxgl.Map() so both run concurrently",
"Identifies new mapboxgl.Popup() on every click as a memory leak",
"Recommends declaring a single popup instance outside the handler and reusing it",
"Identifies queryRenderedFeatures without a layers filter as expensive",
"Identifies expensive operation on 'move' event (fires ~60x/sec) — recommends 'moveend' or throttling"
]
},
{
"id": 2,
"prompt": "We're building a map showing 75,000 store locations across the US. The previous implementation used the Mapbox Marker class and was completely unusable. My team is debating whether to switch to clustering or symbol layers — which is the right fix, and why?",
"expected_output": "Should recommend symbol layers (GeoJSON source + circle or symbol layer), NOT clustering. 75,000 points is within the symbol layer range (500–100,000 markers) and renders efficiently on the GPU without clustering. Clustering is only needed at 100,000+ markers. Should explain that HTML markers create 75,000 DOM elements (the actual problem) and that symbol layers render all points as a single WebGL draw call at 60 FPS. Clustering at this scale adds complexity without the performance benefit that symbol layers already provide.",
"files": [],
"expectations": [
"Recommends symbol layers (GeoJSON source + circle or symbol layer) as the primary fix",
"Correctly identifies that 75,000 points does NOT require clustering — clustering is for 100,000+ markers",
"Explains that the root cause is HTML markers creating 75,000 DOM elements",
"Explains that symbol layers render on the GPU and handle this scale efficiently",
"Shows or describes the GeoJSON source + addLayer pattern"
]
},
{
"id": 3,
"prompt": "I have a 15 MB GeoJSON file of US census tracts with demographic data that I want to display on a Mapbox map. Should I serve this as GeoJSON directly or convert it to vector tiles? What are the tradeoffs?",
"expected_output": "Should identify that 15 MB is in the borderline range (5–20 MB) where vector tiles are recommended. GeoJSON at 15 MB will cause slow initial load and high memory usage, especially on mobile — the entire dataset is downloaded and parsed upfront. Vector tiles load only the visible viewport on demand, dramatically reducing initial payload. The recommendation should be to convert to vector tiles (e.g., via Mapbox Tiling Service or Tippecanoe), with the tradeoff that GeoJSON is simpler for frequently-changing data.",
"files": [],
"expectations": [
"Identifies that 15 MB is above the threshold where GeoJSON becomes problematic (roughly 5 MB)",
"Recommends vector tiles for a 15 MB dataset",
"Explains that GeoJSON downloads the entire file upfront while vector tiles load only the visible viewport",
"Mentions the performance impact: slow initial load and high memory usage with raw GeoJSON at this size",
"Notes the tradeoff: GeoJSON is simpler for frequently-changing data, vector tiles for static or slow-changing data"
]
}
]
}
Data Loading Optimization
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.
// ✅ Only load data in current viewport
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());
}
// Update on viewport change (with debounce)
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.
// ✅ Load basic data first, add details progressively
async function loadDataProgressive(map) {
// 1. Load simplified data first (low-res)
const simplified = await fetch('/api/data?detail=low');
map.addSource('data', {
type: 'geojson',
data: await simplified.json()
});
addLayers(map);
// 2. Load full detail in background
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.
// ✅ Server generates tiles, client loads only visible area (self-hosted tilesets)
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', // Layer name in .pbf
paint: {
'fill-color': '#088',
'fill-opacity': 0.6
}
});Impact: 10 MB dataset reduced to ~500 KB per viewport load
Optimize Map Interactions
Problem: Unthrottled event handlers cause performance degradation.
Anti-Pattern: Expensive Operations on Every Event
// ❌ BAD: Runs ~60 times per second during pan (once per render frame)
map.on('move', () => {
updateVisibleFeatures(); // Expensive query
fetchDataFromAPI(); // Network request
updateUI(); // DOM manipulation
});Debounce/Throttle Events
// ✅ GOOD: Throttle during interaction, finalize on idle
let throttleTimeout;
// Lightweight updates during move (throttled)
map.on('move', () => {
if (throttleTimeout) return;
throttleTimeout = setTimeout(() => {
updateMapCenter(); // Cheap update
throttleTimeout = null;
}, 100);
});
// Expensive operations after interaction stops
map.on('moveend', () => {
updateVisibleFeatures();
fetchDataFromAPI();
updateUI();
});Optimize Feature Queries
// ❌ BAD: Query all features (expensive with many layers)
map.on('click', (e) => {
const features = map.queryRenderedFeatures(e.point);
console.log(features); // Could be 100+ features from all layers
});
// ✅ GOOD: Query specific layers only
map.on('click', (e) => {
const features = map.queryRenderedFeatures(e.point, {
layers: ['restaurants', 'shops'] // Only query these layers
});
if (features.length > 0) {
showPopup(features[0]);
}
});
// ✅ For touch targets or fuzzy clicks: Use a bounding box
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'] // Further narrow results
});
});Batch DOM Updates
// ❌ BAD: Update DOM for every feature
map.on('mousemove', 'restaurants', (e) => {
e.features.forEach((feature) => {
document.getElementById(feature.id).classList.add('highlight');
});
});
// ✅ GOOD: Batch updates with requestAnimationFrame
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
Layer and Style Performance
Consolidate Layers
// ❌ BAD: 20 separate layers for restaurant types
restaurantTypes.forEach((type) => {
map.addLayer({
id: `restaurants-${type}`,
type: 'symbol',
source: 'restaurants',
filter: ['==', ['get', 'type'], type],
layout: { 'icon-image': `${type}-icon` }
});
});
// ✅ GOOD: Single layer with data-driven styling
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' // fallback
]
}
});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.
// Zoom-dependent paint properties MUST use step or interpolate, not comparisons
// ❌ WRONG: Cannot use comparison operators on ['zoom'] in paint properties
// paint: { 'fill-extrusion-height': ['case', ['>', ['zoom'], 16], ...] }
// ✅ CORRECT: Use step for discrete zoom breakpoints
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'], // Default: use raw height
16,
['*', ['get', 'height'], 1.5] // At zoom 16+: scale up
]
}
});For very large GeoJSON datasets, pre-computing static property derivations (like color categories) into the source data can reduce per-feature expression work:
// ✅ Pre-compute STATIC derivations for large datasets (100K+ features)
const buildingsWithColor = {
type: 'FeatureCollection',
features: buildings.features.map((f) => ({
...f,
properties: {
...f.properties,
heightColor: getColorForHeight(f.properties.height) // Pre-computed once
}
}))
};
map.addSource('buildings', { type: 'geojson', data: buildingsWithColor });
map.addLayer({
id: 'buildings',
type: 'fill-extrusion',
source: 'buildings',
paint: {
'fill-extrusion-color': ['get', 'heightColor'], // Simple property lookup
'fill-extrusion-height': ['get', 'height']
}
});Use Zoom-Based Layer Visibility
// ✅ Only render layers at appropriate zoom levels
map.addLayer({
id: 'building-details',
type: 'fill',
source: 'buildings',
minzoom: 15, // Render at zoom 15 and above
paint: { 'fill-color': '#aaa' }
});
map.addLayer({
id: 'poi-labels',
type: 'symbol',
source: 'pois',
minzoom: 12, // Hide at low zoom levels where labels would overlap heavily
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
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
// ✅ Essential cleanup pattern
function cleanupMap(map) {
if (!map) return;
// 1. Remove event listeners
map.off('load', handleLoad);
map.off('move', handleMove);
// 2. Remove layers (if adding/removing dynamically)
if (map.getLayer('dynamic-layer')) {
map.removeLayer('dynamic-layer');
}
// 3. Remove sources (if adding/removing dynamically)
if (map.getSource('dynamic-source')) {
map.removeSource('dynamic-source');
}
// 4. Remove controls
map.removeControl(navigationControl);
// 5. CRITICAL: Remove map instance
map.remove();
}
// React example
useEffect(() => {
const map = new mapboxgl.Map({
/* config */
});
return () => {
cleanupMap(map); // Called on unmount
};
}, []);Clean Up Popups and Markers
// ❌ BAD: Creates new popup on every click (memory leak)
map.on('click', 'restaurants', (e) => {
new mapboxgl.Popup().setLngLat(e.lngLat).setHTML(e.features[0].properties.name).addTo(map);
// Popup never removed!
});
// ✅ GOOD: Reuse single popup instance
let popup = new mapboxgl.Popup({ closeOnClick: true });
map.on('click', 'restaurants', (e) => {
popup.setLngLat(e.lngLat).setHTML(e.features[0].properties.name).addTo(map);
// Previous popup content replaced, no leak
});
// Cleanup
function cleanup() {
popup.remove();
popup = null;
}Use Feature State Instead of New Layers
// ❌ BAD: Create new layer for hover (memory overhead, causes re-render)
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' }
});
});
// ✅ GOOD: Use feature state (efficient, no layer creation)
map.on('mousemove', 'restaurants', (e) => {
if (e.features.length > 0) {
// Remove previous hover state
if (hoveredFeatureId !== null) {
map.setFeatureState({ source: 'restaurants', id: hoveredFeatureId }, { hover: false });
}
// Set new hover state
hoveredFeatureId = e.features[0].id;
map.setFeatureState({ source: 'restaurants', id: hoveredFeatureId }, { hover: true });
}
});
// Style uses feature state
map.addLayer({
id: 'restaurants',
type: 'circle',
source: 'restaurants',
paint: {
'circle-color': [
'case',
['boolean', ['feature-state', 'hover'], false],
'#ffff00', // Yellow when hover
'#0000ff' // Blue otherwise
]
}
});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
Mobile Performance
Problem: Mobile devices have limited resources (CPU, GPU, memory, battery).
Mobile-Specific Optimizations
// Detect mobile device
const isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent);
const map = new mapboxgl.Map({
container: 'map',
style: 'mapbox://styles/mapbox/streets-v12',
// Mobile optimizations
...(isMobile && {
// Limit max zoom to reduce tile fetching at extreme zoom levels
maxZoom: 18,
// fadeDuration controls symbol collision fade animation only
// Reducing it makes label transitions snappier
fadeDuration: 0
})
});
// Load simpler layers on mobile
map.on('load', () => {
if (isMobile) {
// Circle layers are cheaper than symbol layers (no collision detection,
// no texture atlas, no text shaping)
map.addLayer({
id: 'markers-mobile',
type: 'circle',
source: 'data',
paint: {
'circle-radius': 8,
'circle-color': '#007cbf'
}
});
} else {
// Rich desktop rendering with icons and labels
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
// ✅ Simplify touch gestures
map.touchZoomRotate.disableRotation(); // Disable rotation (simpler gestures, fewer accidental rotations)
// Debounce expensive operations during touch
let touchTimeout;
map.on('touchmove', () => {
if (touchTimeout) clearTimeout(touchTimeout);
touchTimeout = setTimeout(() => {
updateVisibleData();
}, 500); // Wait for touch to settle
});Performance-Sensitive Constructor Options
// These options have real GPU/performance costs -- only enable when needed
const map = new mapboxgl.Map({
container: 'map',
style: 'mapbox://styles/mapbox/streets-v12',
// Default false -- only set true if you need map.getCanvas().toDataURL()
// Costs: prevents GPU buffer optimization
preserveDrawingBuffer: false,
// Default false -- only set true if you need smooth diagonal lines
// Costs: enables MSAA which increases GPU memory and fill cost
antialias: false
});Related skills
How it compares
Use mapbox-web-performance-patterns over generic web perf skills when bottlenecks are specific to Mapbox GL JS initialization and render paths.
FAQ
When should I switch from HTML markers to symbol layers?
Use HTML markers only for < 100 markers. 100-10,000 use symbol layers (GPU-accelerated). 10,000+ use clustering. 100,000+ use vector tiles with server-side clustering.
How do I eliminate initialization waterfalls?
Start data fetch immediately via Promise without waiting for map.on('load'). Pass the Promise to the load handler so data is ready when map initialization completes. This changes waterfall from seq(map, data) to parallel max(map, data).
What are the target performance metrics?
Time to Interactive < 2s on 3G, 60 FPS during pan/zoom, memory growth < 10 MB per hour, initial bundle < 500 KB.
Is Mapbox Web Performance Patterns safe to install?
skills.sh reports 2 of 3 security scanners passed. Review the Security Audits panel on this page before installing in production.